Tabs
A row of tabs that switches between panels sharing one region. The list is one tab stop with a roving focus, and choosing a tab swaps the panel.
Preview
tabs · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/tabs
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { Tabs } from "@/components/ui/tabs"<Tabs
label="Reading detail"
value={panel}
onValueChange={setPanel}
items={[
{ value: "overview", label: "Overview", panel: <Overview /> },
{ value: "history", label: "History", panel: <History /> },
{ value: "notes", label: "Notes", panel: <Notes /> },
]}
/>When to use it
Use it when
- Two or three distinct panels share one region and only one is worth showing at a time.
- Folding secondary views of one subject, such as an overview, a history and notes, behind their own tabs.
- Views a reader moves between often and expects to find in a familiar bar.
Do not use it when
- The choice sets one parameter of a view that stays on the surface, such as a chart's week window.Use
segmented-controlinstead. - The choice is a small set of exclusive options taken as a form input, submitted as a value.Use
radio-groupinstead. - The panels are steps in a sequence, where progress and a next action matter most.Use
stepperinstead.
Anatomy
TabsThe root, a Base UI Tabs.Root on Composite. The list is one tab stop with roving focus.Controlled byvalueTabs.ListThe row holding the tabs, role="tablist", relative for the indicator, with a hairline rail on its bottom edge.Controlled bylabelTabs.TabOne tab, a button with role="tab" and aria-selected. Active lifts ink and weight; disabled drops to muted.Controlled byitemsTabs.IndicatorThe active underline, pinned to the list bottom in chrome primary ink. Decorative; aria-selected is the carrier.Tabs.PanelOne panel per item, role="tabpanel" tied to its tab, shown only while that tab is active.Controlled byitems
- Tabs 1the controlled root
- Tabs.List 1role="tablist"
- Tabs.Tab 2..nrole="tab", aria-selected; one per item
- Tabs.Indicator 1decorative
- Tabs.Panel 2..nrole="tabpanel"; one per item, only the active shown
- Tabs.List 1role="tablist"
Examples
Switching between panels
Three panels share one region, from items. Choosing a tab swaps the whole
panel, not one setting of a view.
tabs-switching-panels · base base · style base-lyraOpen under the product themeTabs in a card
The card header names the subject and items fills its body, so a tab swaps the
body without the card moving.
tabs-in-a-card · base base · style base-lyraOpen under the product themeContent guidelines
Keep tab labels short, parallel and in sentence case: one or two words, the same part of speech, no terminal punctuation. Do not use tabs to hide content a reader needs together, such as a side by side comparison.
Do
"Overview" · "History" · "Notes" name what each tab reveals, in one grammar.
Don’t
"Overview" · "Click for the full history" mixes grammars, and one label instructs.
Accessibility
Audited against WCAG 2.2 AA, in a source pass and a rendered pass at /view.
This is an author-run audit, not an independent review, and clinical review is
still pending.
The audit found nothing to change and applied no fix. Base UI's Tabs carries the
tested WAI-ARIA pattern and the wrapper overrides none of it. Activation is
manual, three sighted carriers plus aria-selected mark the active tab, and the
row draws neutral chrome with no status or category axis. The rendered pass was
clean: no axe violation, no target-size breach, no focus gap, no 320px reflow.
Two items to weigh. Each tab floors its height at --opsin-target-minimum with
padding but sets no min-w, the house treatment for a word-labelled target under
SC 2.5.8. The label prop applies only as the tablist's aria-label, right here
because every tab shows its own text.
Contrast pairs for the active ink, the rail and the indicator stay unmeasured.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus into the tab list, onto the active tab | One tab stop for the list. The active panel is the next stop. |
| Shift+Tab | Moves focus back out of the tab list | One stop in reverse too, so focus leaves the row. |
| Arrow Right, Arrow Down | Moves the highlight to the next tab | No panel swap. Wraps around, skips disabled tabs, mirrored under right-to-left. |
| Arrow Left, Arrow Up | Moves the highlight to the previous tab | Wraps to the last tab, skips disabled tabs, swaps no panel. |
| Home, End | Moves the highlight to the first or last tab | Both skip disabled tabs and neither swaps the panel. |
| Enter, Space | Activates the highlighted tab and shows its panel | The commit step of manual activation. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from TabsProps in registry/bases/base/tabs.tsx.
value and onValueChange make this controlled, with no internal selection
state. A value matching no item renders no active tab and warns in
development. items holds each tab's value, label and panel together.
Related
- SegmentedControl is for a parameter of one view that stays put, such as a chart's window.
- TabBar is navigation between whole sections of an app, each destination its own tab stop.
- Stepper is for panels that are steps in a sequence, where order matters.