Use Tabs to switch between closely related views without navigating to a new page. Match trigger values to TabsContent values. Use links for independently navigable destinations.
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function TabsDemo() {
const [message, setMessage] = useState("");
return (
<div className="grid gap-3">
<Tabs defaultValue="account" className="w-full max-w-[400px]">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">
<form
onSubmit={(event) => {
event.preventDefault();
setMessage("Account preview updated locally.");
}}
>
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>
Edit this local preview. No account request is sent.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="tabs-demo-name">Name</Label>
<Input id="tabs-demo-name" defaultValue="John Doe" required />
</div>
<div className="grid gap-3">
<Label htmlFor="tabs-demo-username">Username</Label>
<Input id="tabs-demo-username" defaultValue="@johndoe" required />
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full">
Save changes
</Button>
</CardFooter>
</Card>
</form>
</TabsContent>
<TabsContent value="password">
<form
onSubmit={(event) => {
event.preventDefault();
setMessage("Password preview updated locally. No credentials were sent.");
}}
>
<Card>
<CardHeader>
<CardTitle>Password</CardTitle>
<CardDescription>
Try the password form locally. It does not update credentials or sign you out.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="tabs-demo-current">Current password</Label>
<Input id="tabs-demo-current" type="password" required />
</div>
<div className="grid gap-3">
<Label htmlFor="tabs-demo-new">New password</Label>
<Input id="tabs-demo-new" type="password" required />
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full">
Save password
</Button>
</CardFooter>
</Card>
</form>
</TabsContent>
</Tabs>
<output className="block min-h-5 text-center text-sm" aria-live="polite">
{message}
</output>
</div>
);
}
#Installation
Complete the base setup. For manual installation:
First time? Install the shared base once.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/tabs.jsonnpm install @base-ui/react@^1.8.0 class-variance-authority@^0.7.1Create components/ui/tabs.tsx:
"use client";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { DirectionProvider } from "@base-ui/react/direction-provider";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
function Tabs({
className,
dir,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root> & { dir?: "ltr" | "rtl" }) {
const root = (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
dir={dir}
orientation={orientation}
className={(state) =>
cn(
"group/tabs w-full data-[orientation=vertical]:flex data-[orientation=vertical]:gap-2",
typeof className === "function" ? className(state) : className,
)
}
{...props}
/>
);
return dir ? <DirectionProvider direction={dir}>{root}</DirectionProvider> : root;
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-base border-2 border-border bg-background p-1 text-foreground group-data-[orientation=horizontal]/tabs:h-12 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none data-[variant=line]:border-0 data-[variant=line]:bg-transparent",
{
variants: { variant: { default: "", line: "gap-1" } },
defaultVariants: { variant: "default" },
},
);
function TabsList({
className,
variant = "default",
activateOnFocus = true,
...props
}: React.ComponentProps<typeof TabsPrimitive.List> & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
activateOnFocus={activateOnFocus}
className={(state) =>
cn(
tabsListVariants({ variant }),
typeof className === "function" ? className(state) : className,
)
}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Tab>) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={(state) =>
cn(
"relative inline-flex items-center justify-center gap-1.5 rounded-base border-2 border-transparent px-2 py-1 text-sm font-heading whitespace-nowrap transition-all group-data-[orientation=horizontal]/tabs:h-full group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:border-border data-active:bg-main data-active:text-main-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:data-active:border-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
typeof className === "function" ? className(state) : className,
)
}
{...props}
/>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Panel>) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={(state) =>
cn(
"mt-2 outline-none focus-visible:ring-1 focus-visible:ring-ring",
typeof className === "function" ? className(state) : className,
)
}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
The live preview's forms additionally use Button, Card, Input, and Label. Install them before copying the Code example:
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/button.json https://neobrutal-ui.andongmin.com/r/card.json https://neobrutal-ui.andongmin.com/r/input.json https://neobrutal-ui.andongmin.com/r/label.json#Usage
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
export default function ProjectTabs() {
return (
<Tabs defaultValue="overview" className="w-full max-w-md">
<TabsList aria-label="Project views">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="overview">Project summary and current goals.</TabsContent>
<TabsContent value="activity">No recent activity.</TabsContent>
</Tabs>
);
}#API reference
| Prop / part | Purpose | Default |
|---|---|---|
Tabs.value / onValueChange | Controlled active panel | Uncontrolled |
Tabs.defaultValue | Initial panel; specify a matching trigger value | Base UI default |
Tabs.orientation | Horizontal or vertical navigation | horizontal |
TabsList.activateOnFocus | Activate with arrow-key focus; false requires Enter or Space | true |
TabsList.loopFocus | Wrap focus at the ends | true |
TabsList.variant | default or line styling | default |
TabsTrigger.value / TabsContent.value | Matching panel identifier | Required |
TabsTrigger.disabled | Unavailable tab | false |
TabsContent.keepMounted | Retain an inactive panel in the DOM | false |
Each part accepts a string or state-based className function. For example, TabsTrigger exposes active and disabled, while TabsContent exposes hidden.
Tabs delegates selection, keyboard navigation, refs, and rendering to Base UI. Use its render prop for element composition and activateOnFocus for manual activation; no second state or keyboard implementation is maintained.
#Accessibility
Name the tab list. Keep labels short and test arrows, Home/End, and Tab into the panel. Set activateOnFocus={false} on TabsList when opening a panel triggers expensive work. Do not hide critical instructions behind an undiscoverable tab.