Skip to content
Primitiv home
Framework
Consumption mode

Tabs

stableSource Figma

An accessible tabbed interface — a tablist of triggers that switch between panels (WAI-ARIA Tabs pattern).

Playground

Density

Preview

What the component is for.
Size
Justify
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
<Tabs defaultValue="overview" size="md">  <TabsList label="Component docs" justify="start">    <TabsTrigger value="overview">Overview</TabsTrigger>    <TabsTrigger value="props">Props</TabsTrigger>    <TabsTrigger value="a11y">Accessibility</TabsTrigger>  </TabsList>
  <TabsContent value="overview">What the component is for.</TabsContent>  <TabsContent value="props">Every prop, generated from source.</TabsContent>  <TabsContent value="a11y">Roles, keys and focus.</TabsContent></Tabs>

Density is set by a data-density ancestor — the Context system, not a Tabs prop.

Installation

npx 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

<Tabs defaultValue="overview">  <TabsList label="Component docs">    <TabsTrigger value="overview">Overview</TabsTrigger>    <TabsTrigger value="props">Props</TabsTrigger>    <TabsTrigger value="a11y">Accessibility</TabsTrigger>  </TabsList>
  <TabsContent value="overview">What the component is for.</TabsContent>  <TabsContent value="props">Every prop, generated from source.</TabsContent>  <TabsContent value="a11y">Roles, keys and focus.</TabsContent></Tabs>

Props

Tabs.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
activationMode"automatic" | "manual""automatic"headlessWhen a focused trigger becomes active; see TabsActivationMode.
defaultValuestringheadlessValue of the tab active on first render.
dir"ltr" | "rtl"headlessReading direction; see TabsReadingDirection. Inherited from the nearest DirectionProvider when omitted, falling back to "ltr".
lazyMountbooleanheadlessWhen 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) => voidheadlessFired on every user-driven activation with the activated tab's metadata.
onValueChange(value: string) => voidheadlessCalled with the requested value when the user activates a tab.
orientation"horizontal" | "vertical""horizontal"headlessLayout axis; see TabsOrientation.
valuestringheadlessValue of the currently active tab.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledControl 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.

PropTypeDefaultFromDescription
ariaLabelledBystringheadlessId of an existing element to use as the tablist's accessible name, set as aria-labelledby. Mutually exclusive with label.
labelstringheadlessAccessible 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"startstyledAlignment 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.

PropTypeDefaultFromDescription
value (required)stringheadlessIdentifies this trigger, linking it to the Tabs.Content of the same value. Not the visible label — use children for that.
asChildbooleanheadlessRender 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.
disabledbooleanfalseheadlessRemoves the trigger from the roving tab order and marks it aria-disabled/data-disabled; arrow-key navigation skips it.
refRef<T>headlessRef 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.

PropTypeDefaultFromDescription
value (required)stringheadlessValue of the trigger this panel belongs to.

Styling contract

--primitiv-tabs-trigger-height--primitiv-tabs-trigger-padding-inline--primitiv-tabs-trigger-gap--primitiv-tabs-trigger-icon-size--primitiv-tabs-trigger-fg--primitiv-tabs-trigger-fg-active--primitiv-tabs-trigger-fg-hover--primitiv-tabs-baseline-color--primitiv-tabs-indicator-height--primitiv-tabs-indicator-color--primitiv-tabs-indicator-color-active--primitiv-tabs-trigger-font-family--primitiv-tabs-trigger-font-size--primitiv-tabs-trigger-font-weight--primitiv-tabs-trigger-line-height--primitiv-tabs-panel-padding-block--primitiv-tabs-panel-padding-inline--primitiv-tabs-panel-fg--primitiv-tabs-panel-font-family--primitiv-tabs-panel-font-size--primitiv-tabs-panel-font-weight--primitiv-tabs-panel-line-height

Keyboard

KeyBehaviour
ArrowRight / ArrowLeftMove between triggers in a horizontal tablist. dir="rtl" swaps them, either as a prop or from a DirectionProvider.
ArrowDown / ArrowUpMove between triggers when orientation is vertical.
Home / EndJump to the first / last trigger.
Enter / SpaceActivate the focused trigger. Only meaningful under activationMode="manual" — automatic activation has already happened on the arrow key.
TabMove out of the tablist and into the active panel, which is the next tab stop.

Data attributes

Tabs

className: .primitiv-tabs

AttributeValueWhen
data-orientationhorizontal | verticalthe root's `orientation`

TabsList

className: .primitiv-tabs__list

AttributeValueWhen
data-orientationhorizontal | verticalthe root's `orientation`

TabsTrigger

className: .primitiv-tabs__trigger

AttributeValueWhen
data-stateactive | inactivewhether this tab is the active one
data-orientationhorizontal | verticalthe root's `orientation`
data-disabled""disabled

TabsContent

className: .primitiv-tabs__panel

AttributeValueWhen
data-stateactive | inactivewhether this panel's tab is active
data-orientationhorizontal | verticalthe 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

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.

Density
Every prop, generated from source.

Active value: props

import { useState } from "react";import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
const [value, setValue] = useState("props");
<Tabs value={value} onValueChange={setValue}>  {/* ... the same List and Content parts ... */}</Tabs>

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.

Density
What the component is for.
What the component is for.
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
// Automatic — the default; the panel follows the arrow keys.<Tabs defaultValue="overview">  <TabsList label="Component docs">    <TabsTrigger value="overview">Overview</TabsTrigger>    <TabsTrigger value="props">Props</TabsTrigger>    <TabsTrigger value="a11y">Accessibility</TabsTrigger>  </TabsList>
  <TabsContent value="overview">What the component is for.</TabsContent>  <TabsContent value="props">Every prop, generated from source.</TabsContent>  <TabsContent value="a11y">Roles, keys and focus.</TabsContent></Tabs>
// Manual — arrows move focus, Enter/Space commits.<Tabs defaultValue="overview" activationMode="manual">  <TabsList label="Component docs">    <TabsTrigger value="overview">Overview</TabsTrigger>    <TabsTrigger value="props">Props</TabsTrigger>    <TabsTrigger value="a11y">Accessibility</TabsTrigger>  </TabsList>
  <TabsContent value="overview">What the component is for.</TabsContent>  <TabsContent value="props">Every prop, generated from source.</TabsContent>  <TabsContent value="a11y">Roles, keys and focus.</TabsContent></Tabs>

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.

Density
What the component is for.
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
<Tabs defaultValue="overview" orientation="vertical">  <TabsList label="Component docs">    <TabsTrigger value="overview">Overview</TabsTrigger>    <TabsTrigger value="props">Props</TabsTrigger>    <TabsTrigger value="a11y">Accessibility</TabsTrigger>  </TabsList>
  <TabsContent value="overview">What the component is for.</TabsContent>  <TabsContent value="props">Every prop, generated from source.</TabsContent>  <TabsContent value="a11y">Roles, keys and focus.</TabsContent></Tabs>

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.

Density
What the component is for.
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
<Tabs defaultValue="overview">  <TabsList label="Component docs">    <TabsTrigger value="overview">Overview</TabsTrigger>    <TabsTrigger value="props">Props</TabsTrigger>    <TabsTrigger value="a11y" disabled>Accessibility</TabsTrigger>  </TabsList>
  <TabsContent value="overview">What the component is for.</TabsContent>  <TabsContent value="props">Every prop, generated from source.</TabsContent>  <TabsContent value="a11y">Roles, keys and focus.</TabsContent></Tabs>