Switch between related panels of content without leaving the page. Three styles (line, segmented, enclosed), two sizes, horizontal or vertical, with an animated selection indicator, keyboard navigation and support for tabs that are links.
Each Tab is matched to the TabPanel with the same id. Only the selected panel is mounted. The first tab is selected by default; use defaultSelectedKey or selectedKey to change that.
Label the tab list
TabList renders role="tablist" and needs an accessible name. Pass
aria-label, or aria-labelledby pointing at a visible heading.
Tabs — switch between peer views of the same object (a project's overview, deployments and settings) where only one view is needed at a time.
Toggle Button Group — pick a value or mode that changes content in place without panels, such as a chart's metric. Segmented tabs look similar; use tabs only when each option has its own panel.
Disclosure — sections that users may want open at the same time, or content that should stay in reading order.
Sidebar or Links — primary navigation between pages. Tabs can be links (see Tabs as links), but keep them to a small set of sibling routes.
<Tabs> {/* state, orientation, variant and size */} <TabList> {/* role="tablist" */} <Tab> {/* role="tab", or a link when href is set */} <Icon /> {/* optional, auto-sized */} Label <Badge /> {/* optional count or status */} </Tab> </TabList> <TabPanel /> {/* role="tabpanel", one per tab */}</Tabs>
Part
Renders
Notes
Tabs
<div>
Root. Holds selection state and passes variant and size to its parts through context. A column when horizontal, a row when vertical.
TabList
<div role="tablist">
The row (or column) of tabs. One tab stop; arrow keys move between tabs.
Tab
<div role="tab"> or <a>
A tab. Needs an id that matches a panel. Renders a link when given href.
Selection indicator
SelectionIndicator
Rendered inside every tab. Only the selected tab's indicator shows; it slides between tabs. Hidden for enclosed.
TabPanel
<div role="tabpanel">
Content for the tab with the same id. Labelled by its tab.
A pill-shaped track with a raised thumb. Suits short, mutually exclusive views such as billing periods or chart ranges, and fits inside toolbars and card headers.
Monthly
Yearly
$12 per seat, billed monthly.
import { Tab, TabList, TabPanel, Tabs } from "@/components/ui/tabs";export default function TabsSegmented() { return ( <Tabs variant="segmented" className="w-full max-w-md"> <TabList aria-label="Billing period"> <Tab id="monthly">Monthly</Tab> <Tab id="yearly">Yearly</Tab> </TabList> <TabPanel id="monthly" className="text-muted-foreground"> $12 per seat, billed monthly. </TabPanel> <TabPanel id="yearly" className="text-muted-foreground"> $10 per seat, billed yearly. Save 17%. </TabPanel> </Tabs> );}
Folder-style tabs that join the panel below. Give the panel a border without a top edge (border border-t-0) and remove the root gap (gap-0) so they connect. Good for editors and code samples.
page.tsx
layout.tsx
globals.css
export default function Page() {
return <h1>Hello, world</h1>;
}
Tabs don't wrap or shrink. A horizontal TabList watches its size, and when the tabs are wider than the space it has (many tabs, long labels, small screens) it gets data-overflowing and scrolls horizontally, with no vertical scrollbar and the indicator kept inside the scroll area. The selected tab is scrolled into view on mount and whenever the selection changes; tabs reached with the arrow keys scroll into view as they get focus. Pass hideScrollbar to hide the (thin) scrollbar; the list still scrolls with touch, a trackpad, Shift+wheel and the arrow keys.
The list is capped at its container's width (max-w-full, min-w-0), so this also works when the TabList sits in a flex row next to other controls. Lists that fit are left as they are.
orientation="vertical" stacks the tabs beside the panel, and adds up and down arrow navigation. Give the TabList a fixed width. Useful for settings pages with many sections.
Disable individual tabs with disabledKeys on Tabs or isDisabled on a Tab, or every tab with isDisabled on Tabs. Disabled tabs are skipped by the keyboard. Tell users why a tab is unavailable, for example with an icon or a note in the page.
Members
Roles
Audit log
12 members across 3 teams.
import { LockIcon } from "lucide-react";import { Tab, TabList, TabPanel, Tabs } from "@/components/ui/tabs";export default function TabsDisabled() { return ( <Tabs className="w-full max-w-md" disabledKeys={["audit-log"]}> <TabList aria-label="Workspace"> <Tab id="members">Members</Tab> <Tab id="roles">Roles</Tab> <Tab id="audit-log"> <LockIcon /> Audit log </Tab> </TabList> <TabPanel id="members" className="text-muted-foreground"> 12 members across 3 teams. </TabPanel> <TabPanel id="roles" className="text-muted-foreground"> Owner, Admin, Member and Viewer. </TabPanel> <TabPanel id="audit-log" className="text-muted-foreground"> Audit logs are available on the Enterprise plan. </TabPanel> </Tabs> );}
Own the selection with selectedKey and onSelectionChange when something outside the tabs needs to change it, such as Back and Continue buttons in a multi-step flow.
By default, moving focus with the arrow keys selects the tab immediately. With keyboardActivation="manual", arrows only move focus and Enter or Space selects. Use this when rendering a panel is expensive, for example when it fetches data.
Revenue
Churn
Cohorts
$184,200 this quarter across 1,240 invoices.
import { Tab, TabList, TabPanel, Tabs } from "@/components/ui/tabs";const reports = [ { id: "revenue", label: "Revenue", body: "$184,200 this quarter across 1,240 invoices.", }, { id: "churn", label: "Churn", body: "2.1% monthly churn, down from 2.6% last quarter.", }, { id: "cohorts", label: "Cohorts", body: "Retention by signup month for the last 12 months.", },];export default function TabsManualActivation() { return ( <Tabs keyboardActivation="manual" className="w-full max-w-md"> <TabList aria-label="Reports"> {reports.map((r) => ( <Tab key={r.id} id={r.id}> {r.label} </Tab> ))} </TabList> {reports.map((r) => ( <TabPanel key={r.id} id={r.id} className="text-muted-foreground"> {r.body} </TabPanel> ))} </Tabs> );}
Give each Tab an href to make it a real link (middle-click and "open in new tab" work). Selection then follows the URL: derive selectedKey from the current path, and wrap your app in React Aria's RouterProvider so clicks go through your client-side router. The preview uses a local stand-in for the router.
Line tabs as the header of a Card, with a form in each panel and shared actions in the footer. Pad the TabList with the card's px-(--card-px) so the tabs line up with the content.
Profile
Notifications
Security
Used for sign-in and receipts.
import { Button } from "@/components/ui/button";import { Card, CardContent, CardFooter } from "@/components/ui/card";import { Switch } from "@/components/ui/switch";import { Tab, TabList, TabPanel, Tabs } from "@/components/ui/tabs";import { TextField } from "@/components/ui/text-field";export default function TabsRecipeAccountSettings() { return ( <Card className="w-full max-w-lg"> <Tabs className="gap-0"> <TabList aria-label="Account settings" className="px-(--card-px)"> <Tab id="profile">Profile</Tab> <Tab id="notifications">Notifications</Tab> <Tab id="security">Security</Tab> </TabList> <CardContent className="pt-5"> <TabPanel id="profile" className="grid gap-4"> <TextField label="Display name" defaultValue="Maya Chen" /> <TextField label="Email" type="email" defaultValue="maya@acme.dev" description="Used for sign-in and receipts." /> </TabPanel> <TabPanel id="notifications" className="grid gap-4"> <Switch defaultSelected labelPlacement="start" description="When someone mentions you or replies to your comment." > Mentions and replies </Switch> <Switch labelPlacement="start" description="A summary of activity in your projects, every Monday." > Weekly digest </Switch> </TabPanel> <TabPanel id="security" className="grid gap-4"> <TextField label="Current password" type="password" /> <TextField label="New password" type="password" /> </TabPanel> </CardContent> </Tabs> <CardFooter className="justify-end border-t"> <Button variant="outline">Cancel</Button> <Button>Save changes</Button> </CardFooter> </Card> );}
Small segmented tabs switch the package manager, with a copy button in the same bar. The TabList can sit inside any layout wrapper as long as it's within Tabs.
className on every part accepts a string or a function of its render props, and is merged with the built-in classes with tailwind-merge.
The tab label is wrapped in an inline <span> above the indicator, so icons and badges line up with the text.
The line indicator and selected text use the brand color; the segmented thumb uses card. Override with classes such as data-selected:text-foreground on Tab.
The root has gap-4 between the list and the panels. Change it with className="gap-0" (as in the enclosed examples) or any other gap.