An accessible tabbed interface — a tablist of triggers that switch between panels (WAI-ARIA Tabs pattern).
Playground
Preview
What the component is for.
Every prop, generated from source.
Roles, keys and focus.
import{Tabs,TabsList,TabsTrigger,TabsContent}from"@/components/ui/tabs";<TabsdefaultValue="overview"size="md"><TabsListlabel="Component docs"justify="start"><TabsTriggervalue="overview">Overview</TabsTrigger><TabsTriggervalue="props">Props</TabsTrigger><TabsTriggervalue="a11y">Accessibility</TabsTrigger></TabsList><TabsContentvalue="overview">What the component is for.</TabsContent><TabsContentvalue="props">Every prop, generated from source.</TabsContent><TabsContentvalue="a11y">Roles, keys and focus.</TabsContent></Tabs>
import{Tabs}from"@primitiv-ui/react";<Tabs.RootdefaultValue="overview"><Tabs.Listlabel="Component docs"><Tabs.Triggervalue="overview">Overview</Tabs.Trigger><Tabs.Triggervalue="props">Props</Tabs.Trigger><Tabs.Triggervalue="a11y">Accessibility</Tabs.Trigger></Tabs.List><Tabs.Contentvalue="overview">What the component is for.</Tabs.Content><Tabs.Contentvalue="props">Every prop, generated from source.</Tabs.Content><Tabs.Contentvalue="a11y">Roles, keys and focus.</Tabs.Content></Tabs.Root>
Density is set by a data-density ancestor — the Context system, not a Tabs prop.
Installation
npx primitiv add tabs
pnpm dlx primitiv add tabs
yarn dlx primitiv add tabs
bunx primitiv add tabs
Import
import{Tabs}from"@/components/ui/tabs";
Copied into your project as .primitiv-tabs — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
Four parts, one render path. The value string is the whole wiring — it pairs a Trigger with its Content, and Tabs.Root owns which pair is active.
<TabsdefaultValue="overview"><TabsListlabel="Component docs"><TabsTriggervalue="overview">Overview</TabsTrigger><TabsTriggervalue="props">Props</TabsTrigger><TabsTriggervalue="a11y">Accessibility</TabsTrigger></TabsList><TabsContentvalue="overview">What the component is for.</TabsContent><TabsContentvalue="props">Every prop, generated from source.</TabsContent><TabsContentvalue="a11y">Roles, keys and focus.</TabsContent></Tabs>
<Tabs.RootdefaultValue="overview"><Tabs.Listlabel="Component docs"><Tabs.Triggervalue="overview">Overview</Tabs.Trigger><Tabs.Triggervalue="props">Props</Tabs.Trigger><Tabs.Triggervalue="a11y">Accessibility</Tabs.Trigger></Tabs.List><Tabs.Contentvalue="overview">What the component is for.</Tabs.Content><Tabs.Contentvalue="props">Every prop, generated from source.</Tabs.Content><Tabs.Contentvalue="a11y">Roles, keys and focus.</Tabs.Content></Tabs.Root>
Props
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Tabs.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
activationMode
"automatic" | "manual"
"automatic"
headless
When a focused trigger becomes active; see TabsActivationMode.
defaultValue
string
—
headless
Value of the tab active on first render.
dir
"ltr" | "rtl"
—
headless
Reading direction; see TabsReadingDirection. Inherited from the
nearest DirectionProvider when omitted, falling back to "ltr".
lazyMount
boolean
—
headless
When true, a panel's children are not rendered until that tab is first
activated. Once mounted they remain in the DOM across subsequent tab
switches (lazy mount, not unmount-on-hide). Useful for panels that own
expensive initialisation — e.g. a scroll-snap carousel whose initial
scroll position must be set while the panel is visible.
onChange
({ index, name }: TabMetadata) => void
—
headless
Fired on every user-driven activation with the activated tab's metadata.
onValueChange
(value: string) => void
—
headless
Called with the requested value when the user activates a tab.
orientation
"horizontal" | "vertical"
"horizontal"
headless
Layout axis; see TabsOrientation.
value
string
—
headless
Value of the currently active tab.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Control size for the whole widget; data-density scales each size further.
Tabs.List
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
ariaLabelledBy
string
—
headless
Id of an existing element to use as the tablist's accessible name,
set as aria-labelledby. Mutually exclusive with label.
label
string
—
headless
Accessible name for the tablist, announced as aria-label. Pick a
short, human-readable description of the set (e.g. "Account
sections", not "Tabs"). Mutually exclusive with ariaLabelledBy.
justify
"start" | "center" | "end"
start
styled
Alignment of the triggers along the tablist. Direction-aware — start/end follow the reading direction, flipping under RTL via the inherited DirectionProvider.
Tabs.Trigger
Extends HTMLButtonElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
value* (required)
string
—
headless
Identifies this trigger, linking it to the Tabs.Content of the same
value. Not the visible label — use children for that.
asChild
boolean
—
headless
Render the child element instead of the default <button>. All tab
ARIA attributes and event handlers are merged onto the child. The child
must accept a ref. Useful for routing links that need tab semantics.
disabled
boolean
false
headless
Removes the trigger from the roving tab order and marks it
aria-disabled/data-disabled; arrow-key navigation skips it.
ref
Ref<T>
—
headless
Ref to the rendered element. Defaults to HTMLButtonElement; when using
asChild, specify the child's element type (e.g. HTMLAnchorElement).
Tabs.Content
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
value* (required)
string
—
headless
Value of the trigger this panel belongs to.
Styling contract
22 CSS custom properties on .primitiv-tabs — mode-agnostic. These names are the stable surface; the values are not.
The tablist is a single tab stop, not one per trigger — a roving tabIndex moves within it, so Tab leaves the list rather than walking it. activationMode is automatic by default, so arrow keys activate as they move; under manual they only move focus. A disabled trigger is never activated by any of these keys.
Key
Behaviour
ArrowRight / ArrowLeft
Move between triggers in a horizontal tablist. dir="rtl" swaps them, either as a prop or from a DirectionProvider.
ArrowDown / ArrowUp
Move between triggers when orientation is vertical.
Home / End
Jump to the first / last trigger.
Enter / Space
Activate the focused trigger. Only meaningful under activationMode="manual" — automatic activation has already happened on the arrow key.
Tab
Move out of the tablist and into the active panel, which is the next tab stop.
Data attributes
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
Tabs
className:.primitiv-tabs
Attribute
Value
When
data-orientation
horizontal | vertical
the root's `orientation`
Tabs.Root
Attribute
Value
When
data-orientation
horizontal | vertical
the root's `orientation`
TabsList
className:.primitiv-tabs__list
Attribute
Value
When
data-orientation
horizontal | vertical
the root's `orientation`
Tabs.List
Attribute
Value
When
data-orientation
horizontal | vertical
the root's `orientation`
TabsTrigger
className:.primitiv-tabs__trigger
Attribute
Value
When
data-state
active | inactive
whether this tab is the active one
data-orientation
horizontal | vertical
the root's `orientation`
data-disabled
""
disabled
Tabs.Trigger
Attribute
Value
When
data-state
active | inactive
whether this tab is the active one
data-orientation
horizontal | vertical
the root's `orientation`
data-disabled
""
disabled
TabsContent
className:.primitiv-tabs__panel
Attribute
Value
When
data-state
active | inactive
whether this panel's tab is active
data-orientation
horizontal | vertical
the root's `orientation`
Tabs.Content
Attribute
Value
When
data-state
active | inactive
whether this panel's tab is active
data-orientation
horizontal | vertical
the root's `orientation`
Accessibility
Tabs.List requires either label or ariaLabelledBy — an unnamed role="tablist" is the one thing this component lets you get wrong, so the prop is not optional.
The parts render the full APG trio: role="tablist" on the list, role="tab" on each trigger with aria-selected and aria-controls, and role="tabpanel" on each content with aria-labelledby pointing back at its trigger.
The tablist is a single tab stop. A roving tabIndex follows the active trigger, so Tab moves into the panel rather than through every trigger — the APG behaviour, and the reason a ten-tab list costs a keyboard user one keypress to pass rather than ten.
Inactive panels stay mounted and are hidden with the hidden attribute, so anything stateful inside a panel — a scroll position, a half-filled field — survives a switch. lazyMount defers a panel's first render only; once mounted it stays.
Under asChild the ARIA attributes, the event handlers and the roving tabIndex all merge onto the element you supply, so a custom trigger keeps full tab semantics rather than becoming a div that looks like one.
disabled on a trigger exposes both the native attribute and data-disabled, and no keyboard navigation activates it.
Every part publishes data-orientation, so a vertical tablist is a styling concern rather than a different component or a different keyboard model to learn.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
Controlled
Pass value with onValueChange to own the active tab yourself — for syncing to a URL, or to another control. Pass defaultValue instead to let the component own it. The two are mutually exclusive, which is the one thing the generated props table below cannot show you: Tabs.Root's props are a discriminated union and the table flattens it.
What the component is for.
Every prop, generated from source.
Roles, keys and focus.
Active value: props
import{ useState }from"react";import{Tabs,TabsList,TabsTrigger,TabsContent}from"@/components/ui/tabs";const[value, setValue]=useState("props");<Tabsvalue={value}onValueChange={setValue}>{/* ... the same List and Content parts ... */}</Tabs>
import{ useState }from"react";import{Tabs}from"@primitiv-ui/react";const[value, setValue]=useState("props");<Tabs.Rootvalue={value}onValueChange={setValue}>{/* ... the same List and Content parts ... */}</Tabs.Root>
Activation mode
automatic (the default) activates a tab as the arrow keys reach it, so the panel follows focus. manual moves focus only and waits for Enter or Space. Focus a trigger below and use the arrow keys to feel the difference — automatic is right for cheap panels, manual for a panel that fetches.
What the component is for.
Every prop, generated from source.
Roles, keys and focus.
What the component is for.
Every prop, generated from source.
Roles, keys and focus.
import{Tabs,TabsList,TabsTrigger,TabsContent}from"@/components/ui/tabs";// Automatic — the default; the panel follows the arrow keys.<TabsdefaultValue="overview"><TabsListlabel="Component docs"><TabsTriggervalue="overview">Overview</TabsTrigger><TabsTriggervalue="props">Props</TabsTrigger><TabsTriggervalue="a11y">Accessibility</TabsTrigger></TabsList><TabsContentvalue="overview">What the component is for.</TabsContent><TabsContentvalue="props">Every prop, generated from source.</TabsContent><TabsContentvalue="a11y">Roles, keys and focus.</TabsContent></Tabs>// Manual — arrows move focus, Enter/Space commits.<TabsdefaultValue="overview"activationMode="manual"><TabsListlabel="Component docs"><TabsTriggervalue="overview">Overview</TabsTrigger><TabsTriggervalue="props">Props</TabsTrigger><TabsTriggervalue="a11y">Accessibility</TabsTrigger></TabsList><TabsContentvalue="overview">What the component is for.</TabsContent><TabsContentvalue="props">Every prop, generated from source.</TabsContent><TabsContentvalue="a11y">Roles, keys and focus.</TabsContent></Tabs>
import{Tabs}from"@primitiv-ui/react";// Automatic — the default; the panel follows the arrow keys.<Tabs.RootdefaultValue="overview"><Tabs.Listlabel="Component docs"><Tabs.Triggervalue="overview">Overview</Tabs.Trigger><Tabs.Triggervalue="props">Props</Tabs.Trigger><Tabs.Triggervalue="a11y">Accessibility</Tabs.Trigger></Tabs.List><Tabs.Contentvalue="overview">What the component is for.</Tabs.Content><Tabs.Contentvalue="props">Every prop, generated from source.</Tabs.Content><Tabs.Contentvalue="a11y">Roles, keys and focus.</Tabs.Content></Tabs.Root>// Manual — arrows move focus, Enter/Space commits.<Tabs.RootdefaultValue="overview"activationMode="manual"><Tabs.Listlabel="Component docs"><Tabs.Triggervalue="overview">Overview</Tabs.Trigger><Tabs.Triggervalue="props">Props</Tabs.Trigger><Tabs.Triggervalue="a11y">Accessibility</Tabs.Trigger></Tabs.List><Tabs.Contentvalue="overview">What the component is for.</Tabs.Content><Tabs.Contentvalue="props">Every prop, generated from source.</Tabs.Content><Tabs.Contentvalue="a11y">Roles, keys and focus.</Tabs.Content></Tabs.Root>
Vertical orientation
orientation="vertical" stacks the tablist beside the panel and switches the arrow keys to ArrowUp/ArrowDown. It is one prop: every part publishes data-orientation, and the stylesheet does the rest — the axis is not a separate component.
What the component is for.
Every prop, generated from source.
Roles, keys and focus.
import{Tabs,TabsList,TabsTrigger,TabsContent}from"@/components/ui/tabs";<TabsdefaultValue="overview"orientation="vertical"><TabsListlabel="Component docs"><TabsTriggervalue="overview">Overview</TabsTrigger><TabsTriggervalue="props">Props</TabsTrigger><TabsTriggervalue="a11y">Accessibility</TabsTrigger></TabsList><TabsContentvalue="overview">What the component is for.</TabsContent><TabsContentvalue="props">Every prop, generated from source.</TabsContent><TabsContentvalue="a11y">Roles, keys and focus.</TabsContent></Tabs>
import{Tabs}from"@primitiv-ui/react";<Tabs.RootdefaultValue="overview"orientation="vertical"><Tabs.Listlabel="Component docs"><Tabs.Triggervalue="overview">Overview</Tabs.Trigger><Tabs.Triggervalue="props">Props</Tabs.Trigger><Tabs.Triggervalue="a11y">Accessibility</Tabs.Trigger></Tabs.List><Tabs.Contentvalue="overview">What the component is for.</Tabs.Content><Tabs.Contentvalue="props">Every prop, generated from source.</Tabs.Content><Tabs.Contentvalue="a11y">Roles, keys and focus.</Tabs.Content></Tabs.Root>
Disabled trigger
disabled on a Trigger sets the native attribute and exposes data-disabled. Keyboard navigation never activates it — arrow keys, Home and End all leave the current panel in place rather than opening a disabled one.
What the component is for.
Every prop, generated from source.
Roles, keys and focus.
import{Tabs,TabsList,TabsTrigger,TabsContent}from"@/components/ui/tabs";<TabsdefaultValue="overview"><TabsListlabel="Component docs"><TabsTriggervalue="overview">Overview</TabsTrigger><TabsTriggervalue="props">Props</TabsTrigger><TabsTriggervalue="a11y"disabled>Accessibility</TabsTrigger></TabsList><TabsContentvalue="overview">What the component is for.</TabsContent><TabsContentvalue="props">Every prop, generated from source.</TabsContent><TabsContentvalue="a11y">Roles, keys and focus.</TabsContent></Tabs>
import{Tabs}from"@primitiv-ui/react";<Tabs.RootdefaultValue="overview"><Tabs.Listlabel="Component docs"><Tabs.Triggervalue="overview">Overview</Tabs.Trigger><Tabs.Triggervalue="props">Props</Tabs.Trigger><Tabs.Triggervalue="a11y"disabled>Accessibility</Tabs.Trigger></Tabs.List><Tabs.Contentvalue="overview">What the component is for.</Tabs.Content><Tabs.Contentvalue="props">Every prop, generated from source.</Tabs.Content><Tabs.Contentvalue="a11y">Roles, keys and focus.</Tabs.Content></Tabs.Root>