---
title: "Tabs"
description: "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."
url: "https://opsinjs.pensievelabs.org/components/tabs"
source: "https://opsinjs.pensievelabs.org/components/tabs.md"
section: "Components"
status: "shipped"
kind: "component"
category: "navigation"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["tab list", "view switcher"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="tabs"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured: the active tab against the row, the tab-list rail, or the indicator bar against its ground.&#x22;,
  &#x22;Nobody has listened to this control in a screen reader, so the roving focus and the panel association are untested.&#x22;,
  &#x22;Forced colours and prefers-contrast: more are unhandled, so whether the selection survives the platform's own pair has not been tested.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="tabs" />

## Installation [#installation]

<ComponentInstall name="tabs" unbuilt="false" importPath="@/components/ui/tabs" dependencies="[&#x22;@base-ui/react&#x22;]" />

## Usage [#usage]

```tsx
import { Tabs } from "@/components/ui/tabs"
```

```tsx
<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 [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;Two or three distinct panels share one region and only one is worth showing at a time.&#x22;,
  &#x22;Folding secondary views of one subject, such as an overview, a history and notes, behind their own tabs.&#x22;,
  &#x22;Views a reader moves between often and expects to find in a familiar bar.&#x22;,
]"
  avoid="[
  { case: &#x22;The choice sets one parameter of a view that stays on the surface, such as a chart's week window.&#x22;, instead: &#x22;segmented-control&#x22; },
  { case: &#x22;The choice is a small set of exclusive options taken as a form input, submitted as a value.&#x22;, instead: &#x22;radio-group&#x22; },
  { case: &#x22;The panels are steps in a sequence, where progress and a next action matter most.&#x22;, instead: &#x22;stepper&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="tabs"
  parts="[
  {
    name: &#x22;Tabs&#x22;,
    describes: &#x22;The root, a Base UI Tabs.Root on Composite. The list is one tab stop with roving focus.&#x22;,
    prop: &#x22;value&#x22;,
  },
  {
    name: &#x22;Tabs.List&#x22;,
    describes: &#x22;The row holding the tabs, role=\&#x22;tablist\&#x22;, relative for the indicator, with a hairline rail on its bottom edge.&#x22;,
    prop: &#x22;label&#x22;,
  },
  {
    name: &#x22;Tabs.Tab&#x22;,
    describes: &#x22;One tab, a button with role=\&#x22;tab\&#x22; and aria-selected. Active lifts ink and weight; disabled drops to muted.&#x22;,
    prop: &#x22;items&#x22;,
  },
  {
    name: &#x22;Tabs.Indicator&#x22;,
    describes: &#x22;The active underline, pinned to the list bottom in chrome primary ink. Decorative; aria-selected is the carrier.&#x22;,
  },
  {
    name: &#x22;Tabs.Panel&#x22;,
    describes: &#x22;One panel per item, role=\&#x22;tabpanel\&#x22; tied to its tab, shown only while that tab is active.&#x22;,
    prop: &#x22;items&#x22;,
  },
]"
/>

<CompositionTree
  name="tabs"
  tree="[
  {
    part: &#x22;Tabs&#x22;,
    cardinality: &#x22;1&#x22;,
    note: &#x22;the controlled root&#x22;,
    children: [
      {
        part: &#x22;Tabs.List&#x22;,
        cardinality: &#x22;1&#x22;,
        note: &#x22;role=\&#x22;tablist\&#x22;&#x22;,
        children: [
          {
            part: &#x22;Tabs.Tab&#x22;,
            cardinality: &#x22;2..n&#x22;,
            note: &#x22;role=\&#x22;tab\&#x22;, aria-selected; one per item&#x22;,
          },
          {
            part: &#x22;Tabs.Indicator&#x22;,
            cardinality: &#x22;1&#x22;,
            note: &#x22;decorative&#x22;,
          },
        ],
      },
      {
        part: &#x22;Tabs.Panel&#x22;,
        cardinality: &#x22;2..n&#x22;,
        note: &#x22;role=\&#x22;tabpanel\&#x22;; one per item, only the active shown&#x22;,
      },
    ],
  },
]"
/>

## Examples [#examples]

### Switching between panels [#switching-between-panels]

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

<ComponentPreview name="tabs-switching-panels" kind="example" align="start" />

### Tabs in a card [#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.

<ComponentPreview name="tabs-in-a-card" kind="example" align="start" />

## Content guidelines [#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.

<DoDont>
  <DoDont.Do>
    **"Overview" · "History" · "Notes"** name what each tab reveals, in one grammar.
  </DoDont.Do>

  <DoDont.Dont>
    **"Overview" · "Click for the full history"** mixes grammars, and one label instructs.
  </DoDont.Dont>
</DoDont>

## Accessibility [#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.

<KeyboardTable
  name="tabs"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus into the tab list, onto the active tab&#x22;,
    notes: &#x22;One tab stop for the list. The active panel is the next stop.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back out of the tab list&#x22;,
    notes: &#x22;One stop in reverse too, so focus leaves the row.&#x22;,
  },
  {
    keys: &#x22;Arrow Right, Arrow Down&#x22;,
    action: &#x22;Moves the highlight to the next tab&#x22;,
    notes: &#x22;No panel swap. Wraps around, skips disabled tabs, mirrored under right-to-left.&#x22;,
  },
  {
    keys: &#x22;Arrow Left, Arrow Up&#x22;,
    action: &#x22;Moves the highlight to the previous tab&#x22;,
    notes: &#x22;Wraps to the last tab, skips disabled tabs, swaps no panel.&#x22;,
  },
  {
    keys: &#x22;Home, End&#x22;,
    action: &#x22;Moves the highlight to the first or last tab&#x22;,
    notes: &#x22;Both skip disabled tabs and neither swaps the panel.&#x22;,
  },
  {
    keys: &#x22;Enter, Space&#x22;,
    action: &#x22;Activates the highlighted tab and shows its panel&#x22;,
    notes: &#x22;The commit step of manual activation.&#x22;,
  },
]"
/>

<ContrastReport component="tabs" />

## API reference [#api-reference]

<PropsTable name="TabsProps" />

`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 [#related]

* [SegmentedControl](./segmented-control.mdx) is for a parameter of one view that stays put, such as a chart's window.
* [TabBar](./tab-bar.mdx) is navigation between whole sections of an app, each destination its own tab stop.
* [Stepper](./stepper.mdx) is for panels that are steps in a sequence, where order matters.
