opsinjs
ComponentsNavigation

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

Theme
Density
Text
Status
tabs · base base · style base-lyraOpen under the product theme

Installation

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-control instead.
  • The choice is a small set of exclusive options taken as a form input, submitted as a value.Use radio-group instead.
  • The panels are steps in a sequence, where progress and a next action matter most.Use stepper instead.

Anatomy

  1. TabsThe root, a Base UI Tabs.Root on Composite. The list is one tab stop with roving focus.Controlled by value
  2. Tabs.ListThe row holding the tabs, role="tablist", relative for the indicator, with a hairline rail on its bottom edge.Controlled by label
  3. Tabs.TabOne tab, a button with role="tab" and aria-selected. Active lifts ink and weight; disabled drops to muted.Controlled by items
  4. Tabs.IndicatorThe active underline, pinned to the list bottom in chrome primary ink. Decorative; aria-selected is the carrier.
  5. Tabs.PanelOne panel per item, role="tabpanel" tied to its tab, shown only while that tab is active.Controlled by items
  • 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

Examples

Switching between panels

Three panels share one region, from items. Choosing a tab swaps the whole panel, not one setting of a view.

Theme
Density
Text
Status
tabs-switching-panels · base base · style base-lyraOpen under the product theme

Tabs in a card

The card header names the subject and items fills its body, so a tab swaps the body without the card moving.

Theme
Density
Text
Status
tabs-in-a-card · base base · style base-lyraOpen under the product theme

Content 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.

KeyActionNotes
TabMoves focus into the tab list, onto the active tabOne tab stop for the list. The active panel is the next stop.
Shift+TabMoves focus back out of the tab listOne stop in reverse too, so focus leaves the row.
Arrow Right, Arrow DownMoves the highlight to the next tabNo panel swap. Wraps around, skips disabled tabs, mirrored under right-to-left.
Arrow Left, Arrow UpMoves the highlight to the previous tabWraps to the last tab, skips disabled tabs, swaps no panel.
Home, EndMoves the highlight to the first or last tabBoth skip disabled tabs and neither swaps the panel.
Enter, SpaceActivates the highlighted tab and shows its panelThe commit step of manual activation.
PairThemeAPCA LcWCAG 2.2Floor
body text on the pagelight101.617.18:1Pass
body text on the pagedark-100.518.00:1Pass
body text on a cardlight104.717.96:1Pass
body text on a carddark-99.616.32:1Pass
body text on the muted groundlight98.116.32:1Pass
body text on the muted grounddark-97.513.77:1Pass
secondary text on the pagelight83.97.42:1Pass
secondary text on the pagedark-80.713.44:1Pass
secondary text on a cardlight87.07.76:1Pass
secondary text on a carddark-79.812.19:1Pass
secondary text on the muted groundlight80.47.05:1Pass
secondary text on the muted grounddark-77.810.28:1Pass
a hairline boundary on the pagelight19.31.41:1Below floor
a hairline boundary on the pagedark-8.21.90:1Below floor
a hairline boundary on a cardlight22.41.47:1Below floor
a hairline boundary on a carddark-7.31.72:1Below floor
a hairline boundary on the muted groundlight15.81.34:1Below floor
a hairline boundary on the muted grounddark0.01.45:1Below floor
a placeholder boundary on the pagelight69.84.52:1Pass
a placeholder boundary on the pagedark-51.07.62:1Pass
a placeholder boundary on a cardlight72.84.72:1Pass
a placeholder boundary on a carddark-50.16.91:1Pass
a placeholder boundary on the muted groundlight66.24.29:1Pass
a placeholder boundary on the muted grounddark-48.15.83:1Pass
a placeholder fill on the pagelight8.31.18:1Below floor
a placeholder fill on the pagedark-8.21.90:1Below floor
a placeholder fill on a cardlight11.41.23:1Below floor
a placeholder fill on a carddark-7.31.72:1Below floor
a placeholder fill on the muted groundlight0.01.12:1Below floor
a placeholder fill on the muted grounddark0.01.45:1Below floor
a placeholder boundary on its own filllight58.93.84:1Pass
a placeholder boundary on its own filldark-41.54.01:1Below floor
the card hairline on the pagelight69.84.52:1Pass
the card hairline on the pagedark-28.74.19:1Below floor
the card hairline on a cardlight72.84.72:1Pass
the card hairline on a carddark-27.93.80:1Below 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.

  • 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.

On this page