Tabs
Switches between related views that share a context, showing one panel at a time without leaving the page.
Project overview
12 open issues, 3 pull requests awaiting review, last deploy 18 minutes ago.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export default function TabsDemo() {
return (
<Tabs defaultValue="overview" className="w-full max-w-md">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent
value="overview"
className="rounded-xl border bg-card p-5 shadow-raised"
>
<p className="font-medium text-foreground">Project overview</p>
<p className="mt-1 text-muted-foreground">
12 open issues, 3 pull requests awaiting review, last deploy 18
minutes ago.
</p>
</TabsContent>
<TabsContent
value="activity"
className="rounded-xl border bg-card p-5 shadow-raised"
>
<p className="font-medium text-foreground">Recent activity</p>
<p className="mt-1 text-muted-foreground">
Grace merged “Refine focus rings” and Alan opened two issues.
</p>
</TabsContent>
<TabsContent
value="settings"
className="rounded-xl border bg-card p-5 shadow-raised"
>
<p className="font-medium text-foreground">Project settings</p>
<p className="mt-1 text-muted-foreground">
Rename the project, manage members and configure deployments.
</p>
</TabsContent>
</Tabs>
)
}Anatomy
- 1ListThe rail that holds the tabs: a segmented control by default, a hairline with
variant="line". - 2TriggerA tab. A short noun label, with an optional leading icon.
- 3IndicatorA single element that slides to the active tab.
- 4PanelThe content of the active tab. Focusable, so keyboard users can reach it.
Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/tabs.jsonUsage
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">Account settings.</TabsContent>
<TabsContent value="password">Password settings.</TabsContent>
</Tabs>Every TabsTrigger must live inside TabsList, and each TabsContent pairs with the trigger of the same value. The list renders its own indicator: a single element that glides to the active tab using Base UI's measured position, instead of every tab fading its own background.
Examples
Segmented
The default variant is a segmented control — the active tab rides on a raised pill inside a muted rail. Use it for compact view switches and for tabs inside cards, toolbars and dialogs.
Project overview
12 open issues, 3 pull requests awaiting review, last deploy 18 minutes ago.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export default function TabsDemo() {
return (
<Tabs defaultValue="overview" className="w-full max-w-md">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent
value="overview"
className="rounded-xl border bg-card p-5 shadow-raised"
>
<p className="font-medium text-foreground">Project overview</p>
<p className="mt-1 text-muted-foreground">
12 open issues, 3 pull requests awaiting review, last deploy 18
minutes ago.
</p>
</TabsContent>
<TabsContent
value="activity"
className="rounded-xl border bg-card p-5 shadow-raised"
>
<p className="font-medium text-foreground">Recent activity</p>
<p className="mt-1 text-muted-foreground">
Grace merged “Refine focus rings” and Alan opened two issues.
</p>
</TabsContent>
<TabsContent
value="settings"
className="rounded-xl border bg-card p-5 shadow-raised"
>
<p className="font-medium text-foreground">Project settings</p>
<p className="mt-1 text-muted-foreground">
Rename the project, manage members and configure deployments.
</p>
</TabsContent>
</Tabs>
)
}Line
variant="line" draws a hairline rail with a sliding underline. Use it for page-level sections, where tabs act as a secondary navigation bar under the page title.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const tabs = [
{
value: "overview",
label: "Overview",
body: "Traffic is up 12% week over week, driven by organic search.",
},
{
value: "analytics",
label: "Analytics",
body: "Median session length is 4m 12s across 18,420 sessions.",
},
{
value: "reports",
label: "Reports",
body: "Three scheduled reports go out every Monday at 9:00.",
},
{
value: "alerts",
label: "Alerts",
body: "No alerts in the last 30 days. Thresholds are healthy.",
},
]
export default function TabsLine() {
return (
<Tabs defaultValue="overview" className="w-full max-w-lg">
<TabsList variant="line">
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="text-muted-foreground"
>
{tab.body}
</TabsContent>
))}
</Tabs>
)
}Vertical
Set orientation="vertical" for settings pages and long lists of sections. Arrow keys switch to ↑ and ↓ automatically.
General
Workspace name, URL and default language.
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const sections = [
{
value: "general",
label: "General",
title: "General",
body: "Workspace name, URL and default language.",
},
{
value: "members",
label: "Members",
title: "Members",
body: "Invite people and manage their roles.",
},
{
value: "billing",
label: "Billing",
title: "Billing",
body: "Plan, payment method and invoices.",
},
{
value: "security",
label: "Security",
title: "Security",
body: "Single sign-on, 2FA enforcement and sessions.",
},
]
export default function TabsVertical() {
return (
<Tabs
defaultValue="general"
orientation="vertical"
className="w-full max-w-lg gap-8"
>
<TabsList variant="line" className="w-40 shrink-0">
{sections.map((section) => (
<TabsTrigger key={section.value} value={section.value}>
{section.label}
</TabsTrigger>
))}
</TabsList>
{sections.map((section) => (
<TabsContent key={section.value} value={section.value}>
<p className="text-heading-sm text-foreground">{section.title}</p>
<p className="mt-1 text-muted-foreground">{section.body}</p>
</TabsContent>
))}
</Tabs>
)
}With icons
Icons help people scan tabs that represent layouts or content types. Keep the text label — an icon alone is rarely unambiguous.
import { KanbanIcon, LayoutGridIcon, ListIcon } from "lucide-react"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
export default function TabsIcons() {
return (
<Tabs defaultValue="list">
<TabsList aria-label="Layout">
<TabsTrigger value="list">
<ListIcon />
List
</TabsTrigger>
<TabsTrigger value="grid">
<LayoutGridIcon />
Grid
</TabsTrigger>
<TabsTrigger value="board">
<KanbanIcon />
Board
</TabsTrigger>
</TabsList>
</Tabs>
)
}Disabled
Disable a tab when its view exists but is unavailable, and say why — a short badge or tooltip. A disabled tab can't be activated, but arrow keys still move focus to it, so keyboard and screen reader users can discover it and hear that it's unavailable.
import { Badge } from "@/components/ui/badge"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export default function TabsDisabled() {
return (
<Tabs defaultValue="code" className="w-full max-w-md">
<TabsList variant="line">
<TabsTrigger value="code">Code</TabsTrigger>
<TabsTrigger value="issues">Issues</TabsTrigger>
<TabsTrigger value="deployments" disabled>
Deployments
<Badge variant="secondary" size="sm">
Soon
</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="code" className="text-muted-foreground">
Browse files, branches and commits.
</TabsContent>
<TabsContent value="issues" className="text-muted-foreground">
Track bugs and feature requests.
</TabsContent>
</Tabs>
)
}Controlled
Control value when something other than the tabs changes the view — here a stepper's Continue button.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
const steps = [
{
value: "details",
label: "Details",
body: "Name your project and choose a region.",
},
{
value: "team",
label: "Team",
body: "Invite the people you'll build with.",
},
{
value: "review",
label: "Review",
body: "Confirm everything looks right, then create.",
},
]
export default function TabsControlled() {
const [value, setValue] = React.useState("details")
const index = steps.findIndex((step) => step.value === value)
return (
<Tabs value={value} onValueChange={setValue} className="w-full max-w-md">
<TabsList className="w-full">
{steps.map((step, i) => (
<TabsTrigger key={step.value} value={step.value}>
<span className="font-mono text-xs text-muted-foreground">
{i + 1}
</span>
{step.label}
</TabsTrigger>
))}
</TabsList>
{steps.map((step) => (
<TabsContent
key={step.value}
value={step.value}
className="text-muted-foreground"
>
{step.body}
</TabsContent>
))}
<div className="flex justify-between">
<Button
variant="outline"
disabled={index === 0}
onClick={() => setValue(steps[index - 1].value)}
>
Back
</Button>
<Button
disabled={index === steps.length - 1}
onClick={() => setValue(steps[index + 1].value)}
>
Continue
</Button>
</div>
</Tabs>
)
}Guidelines
When to use
- To organize related content of equal importance that people switch between, without needing to compare it side by side.
- To split long settings or detail pages into sections that share one header and context.
When not to use
- For sequential steps that must be completed in order — use a stepper with explicit Back and Continue, or a single scrolling form.
- For content people need to compare — show it side by side.
- For navigation between pages — tabs switch views in place; links belong in a Navigation Menu or Sidebar.
- For two to five options that set a value — use a Toggle Group.
Labels
Use short, specific nouns in sentence case — one or two words each. Labels must describe their panel so people can predict what they'll see: Members, not More. Don't wrap tab labels onto two lines; if they don't fit, there are too many tabs.
Choosing a variant
Pick one variant per surface and stick with it. line belongs to the page chrome; the segmented default belongs inside components. Never nest one set of tabs in another's panel — it creates two competing places for "where am I".
State and URLs
When tabs represent sections of a page people might share or return to, store the active tab in the URL (for example, ?tab=billing) and control value from it. Keep panels mounted with keepMounted only when their state must survive switching.
Accessibility
Tabs implement the WAI-ARIA tabs pattern: a tablist of tabs, each controlling a tabpanel. The list is a single tab stop with roving focus.
| Key | Behavior |
|---|---|
Tab | Moves focus to the active tab, then into the active panel. |
←→ | In horizontal tabs, moves focus to the previous or next tab. Focus wraps around. |
↑↓ | In vertical tabs, moves focus to the previous or next tab. |
HomeEnd | Moves focus to the first or last tab. |
EnterSpace | Activates the focused tab (when activateOnFocus is off). |
- Manual activation is the default: arrow keys move focus, Enter or Space switches the panel. Set
activateOnFocusonTabsListwhen panels are cheap to render, so focus and selection move together. - Panels are focusable so keyboard users can reach content that has no interactive elements; they show a focus ring when reached with Tab.
- Label the list with
aria-labelwhen there is no visible heading that describes the tabs.
API reference
Tabs
The root. Accepts every prop of the Base UI Tabs.Root.
TabsList
TabsTrigger
TabsContent
tabsListVariants
The class generator for TabsList, exported for building custom lists that match.