# Tabs

_Box Kite 2.1.0 · a markdown copy of https://www.box-kite.dev/tabs/_

The APG tabs pattern: one list of tabs over one panel at a time, with selection following focus and the arrows following the reading order.

**Import**

```jsx
import Tabs from '@box-kite/react/components/tabs';
```

**Usage**

```jsx
<Tabs defaultValue="overview">
  <Tabs.List label="Project">
    <Tabs.Tab value="overview">Overview</Tabs.Tab>
    <Tabs.Tab value="activity">Activity</Tabs.Tab>
    <Tabs.Tab value="settings">Settings</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="overview">Who is on it, and what is left.</Tabs.Panel>
  <Tabs.Panel value="activity">What changed this week.</Tabs.Panel>
  <Tabs.Panel value="settings">Who is allowed to change it.</Tabs.Panel>
</Tabs>
```

## Selection follows focus

One arrow key moves to a tab _and_ shows its panel, which is APG's default and what a reader expects. The three things the pattern asks of the keyboard are all here:

One tab stop for the whole list

Tab enters the list once, landing on the selected tab, and again leaves it for the panel — the roving tabindex, so a twelve-tab list is not twelve stops on the way down the page.

The arrows follow the reading order

Right and Left in a horizontal list, Down and Up in a vertical one, wrapping at the ends and stepping over a disabled tab. In a right-to-left page ArrowLeft is the _next_ tab. The off-axis pair is left to the page, so a horizontal list does not eat a scroll.

Manual activation, when a panel is expensive

`activation="manual"` splits focus from selection: the arrows move, and Enter or Space chooses. Nothing renders on the way past.

## Manual activation

Arrow across these and nothing happens until Enter or Space — worth it when a panel fetches or renders something heavy, and wrong otherwise, since it costs the reader a keystroke per tab.

```jsx
<Tabs defaultValue="daily" activation="manual">
  <Tabs.List label="Report">
    <Tabs.Tab value="daily">Daily</Tabs.Tab>
    <Tabs.Tab value="weekly">Weekly</Tabs.Tab>
    <Tabs.Tab value="yearly">Yearly</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="daily">Today, hour by hour.</Tabs.Panel>
  <Tabs.Panel value="weekly">This week, day by day.</Tabs.Panel>
  <Tabs.Panel value="yearly">Twelve months of it.</Tabs.Panel>
</Tabs>
```

## Vertical

`orientation="vertical"` turns the widget's axis, moves the arrows to Down and Up, and puts the indicator on the inline end — where it mirrors in a right-to-left page, which a border on the right would not.

```jsx
<Tabs defaultValue="general" orientation="vertical">
  <Tabs.List label="Settings">
    <Tabs.Tab value="general">General</Tabs.Tab>
    <Tabs.Tab value="members">Members</Tabs.Tab>
    <Tabs.Tab value="billing">Billing</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="general">Name, description, visibility.</Tabs.Panel>
  <Tabs.Panel value="members">Who is in, and what they may do.</Tabs.Panel>
  <Tabs.Panel value="billing">The plan and the invoices.</Tabs.Panel>
</Tabs>
```

## An indicator that travels

`indicator="sliding"` replaces the border each tab draws with one element for the whole list, which animates between tabs because it _is_ the same element. Where it goes is measured, so it appears once the widget has run — and until then the tabs keep drawing their own, which is what a prerendered page paints and what a reader whose JavaScript never arrives keeps. The travel rides `--transitionTime`, so `prefers-reduced-motion` stops it with no opt-out.

```jsx
<Tabs defaultValue="overview" indicator="sliding">
  <Tabs.List label="Project">
    <Tabs.Tab value="overview">Overview</Tabs.Tab>
    <Tabs.Tab value="activity">Activity</Tabs.Tab>
    <Tabs.Tab value="members">Members and permissions</Tabs.Tab>
    <Tabs.Tab value="logs">Logs</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="overview">Who is on it, and what is left.</Tabs.Panel>
  <Tabs.Panel value="activity">What changed this week.</Tabs.Panel>
  <Tabs.Panel value="members">Who is allowed in, and to do what.</Tabs.Panel>
  <Tabs.Panel value="logs">Every build, newest first.</Tabs.Panel>
</Tabs>
```

It turns with the list: a vertical one puts the bar on the inline end, which is the right-hand side of a left-to-right page and the left of a right-to-left one.

```jsx
<Tabs defaultValue="activity" orientation="vertical" indicator="sliding">
  <Tabs.List label="Views">
    <Tabs.Tab value="overview">Overview</Tabs.Tab>
    <Tabs.Tab value="activity">Activity</Tabs.Tab>
    <Tabs.Tab value="logs">Logs</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="overview">Who is on it.</Tabs.Panel>
  <Tabs.Panel value="activity">What changed.</Tabs.Panel>
  <Tabs.Panel value="logs">Every build.</Tabs.Panel>
</Tabs>
```

## Panels that resize smoothly

Panels of different heights make the page jump under them. Wrap them in a `Tabs.Panels` and that container takes the height of the panel on screen, so the change is a transition instead. It is the only optional part — panels work as plain siblings of the list without it.

```jsx
<Tabs defaultValue="short" indicator="sliding">
  <Tabs.List label="Release">
    <Tabs.Tab value="short">Summary</Tabs.Tab>
    <Tabs.Tab value="long">Changelog</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panels>
    <Tabs.Panel value="short">One line, and the box is one line tall.</Tabs.Panel>
    <Tabs.Panel value="long">
      <Flex d="column" gap={2}>
        <Box>The container measures whichever panel is showing.</Box>
        <Box>It clips only while the height is travelling.</Box>
        <Box>At rest nothing is clipped, so a focus ring at the edge survives.</Box>
        <Box>And the wait is the CSS on the element, so reduced motion has none.</Box>
      </Flex>
    </Tabs.Panel>
  </Tabs.Panels>
</Tabs>
```

It measures the panel, never itself

The container writes its own height, so watching that height would be the loop. What it watches is the panel — every reflow of it, not only a switch — which is how a wrapped line or a late font moves the box with it.

Clipped only while it is moving

A panel already at its full height inside a container still on the way there has to be clipped, or it paints over whatever follows. At rest the clip is gone, because the container is exactly as tall as its panel and a permanent one would cut the focus ring off everything sitting at that edge.

## Disabled tabs

A disabled tab is not selectable and the arrows step over it — the opposite of `Menu.Item`, which APG asks stay reachable. Selection follows focus here, so a tab focus could reach and selection could not would leave the widget with no state to be in.

```jsx
<Tabs defaultValue="overview">
  <Tabs.List label="Project">
    <Tabs.Tab value="overview">Overview</Tabs.Tab>
    <Tabs.Tab value="audit" disabled>Audit log</Tabs.Tab>
    <Tabs.Tab value="activity">Activity</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="overview">Who is on it.</Tabs.Panel>
  <Tabs.Panel value="activity">What changed.</Tabs.Panel>
</Tabs>
```

## Only the selected panel is rendered

An unmounted panel costs nothing and gets an entrance for free, since `startingStyle` runs on the mount — but it also loses whatever state it held. `keepMounted` renders them all and hides the rest, which is what a panel holding a half-filled form wants. Type into the first field, switch away and back:

```jsx
<Tabs defaultValue="details" keepMounted>
  <Tabs.List label="New project">
    <Tabs.Tab value="details">Details</Tabs.Tab>
    <Tabs.Tab value="access">Access</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel value="details"><Textbox props={{ 'aria-label': 'Name' }} /></Tabs.Panel>
  <Tabs.Panel value="access"><Textbox props={{ 'aria-label': 'Owner' }} /></Tabs.Panel>
</Tabs>
```

A hidden panel carries the `hidden` attribute _and_ a `display: none` rule of its own, because every Box carries `display: block` and any author rule outranks the UA's `[hidden]` one.

## Controlled

Pass `value` and the consumer owns the selection; `onValueChange` reports the new tab and why it changed — `'click'` or `'keyboard'`. The tab sequence follows the selection either way, so a value changed from somewhere else on the page moves the keyboard's entry point with it.

```jsx
const [value, setValue] = useState('activity');

<Tabs value={value} onValueChange={(next, { reason }) => { if (next) setValue(next); log(reason); }}>
  …
</Tabs>
```

## A tab is wherever you wrote it

The tabs are read off the DOM rather than out of a registry, so a tab a consumer wrapped in a layout of their own, rendered from a list, or put behind a condition is in the order it was written and navigates like any other — and a nested set of tabs belongs to its own list, not the one around it.

```jsx
<Tabs.List label="Project">
  {views.map((view: { id: string; name: string }) => (
    <Tabs.Tab key={view.id} value={view.id}>{view.name}</Tabs.Tab>
  ))}
</Tabs.List>
```

## Styling

Every part is a Box, so every prop is available, and the defaults live in `Box.components('tabs')` — with `tabs.list`, `tabs.tab` and `tabs.panel` beneath it. The selected state is `aria-selected`, so `ariaAttr={{ selected: … }}` is what styles it on a tab — inside a style tree the same state is the bare `selected` key, but as a _prop_ `selected` writes the attribute the component owns. The indicator is a border rather than a background, because a forced-colors mode throws every background away and selection would otherwise read identically on and off.

```jsx
<Tabs.Tab
  value="overview"
  px={4}
  ariaAttr={{ selected: { color: 'emerald-600', borderColor: 'emerald-500' } }}
>
  Overview
</Tabs.Tab>
```

## Tabs props

Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on [/box](https://www.box-kite.dev/box.md) rather than repeated here.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The tabs and their panels: a `Tabs.List` of `Tabs.Tab`, then one `Tabs.Panel` per tab. |
| `value` | `string` | — | Controlled selection. Leave it out and the widget owns it. |
| `defaultValue` | `string` | — | Which tab starts selected. Left out, nothing is selected and no panel is shown until one is chosen. |
| `onValueChange` | `ChangeHandler<string \| undefined, TabsReason>` | — | Fires with the newly selected tab and why it changed — `'click'` or `'keyboard'`. |
| `orientation` | `TabsOrientation` | `'horizontal'` | Which way the tabs run. Horizontal is the reading axis, so its arrows mirror in a right-to-left page. |
| `activation` | `TabsActivation` | `'automatic'` | Whether moving to a tab selects it. Default `'automatic'`, which is what APG asks for. |
| `loop` | `boolean` | `true` | Whether the arrows wrap around at the ends. Default `true`. |
| `indicator` | `TabsIndicator` | `'static'` | Which indicator marks the selected tab: the tab's own border (`'static'`, the default) or one element travelling between them (`'sliding'`), which animates because it is the same element. |
| `keepMounted` | `boolean` | `false` | Render every panel rather than only the selected one, hiding the rest. What a panel holding a half-filled form needs, since an unmounted panel loses its state. |

### Tabs.List

The tabs themselves, and the keyboard: it sits on the list because roving tabindex puts one tab in the tab sequence.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The tabs. |
| `label` | `string` | — | The list's accessible name. A tablist has none of its own, and one page can hold several. |
| `labelledBy` | `string` | — | Names the list after an element already on the page, instead of `label`. |

### Tabs.Tab

One tab: a real `<button>`, so Enter and Space reach it the way the platform means them to.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `value`required | `string` | — | Which panel this tab shows, and what the widget reports when it is chosen. |
| `children` | `React.ReactNode` | — | The tab's label. |
| `disabled` | `boolean` | — | Not selectable, and stepped over by the arrows — unlike a disabled `Menu.Item`, which APG asks stay reachable. Selection follows focus here, so a tab focus could reach and selection could not would leave the widget with no state to be in. |

### Tabs.Panels

The panels' container, and the only optional part: it takes the height of the panel on screen, so a switch between panels of different heights is a transition rather than a jump. Panels work as plain siblings of the list without it.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The panels. |

### Tabs.Panel

One panel. Rendered only while its tab is selected, unless the widget was told to keep them all.

| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `value`required | `string` | — | The tab this panel belongs to. |
| `children` | `React.ReactNode` | — | The panel's content. |

## Tabs keyboard

| Key | Result |
| --- | --- |
| `Tab` | Enters the list once, landing on the selected tab, and again leaves it for the panel. |
| `Right / Left` | The next and previous tab in a horizontal list, wrapping at the ends and stepping over disabled tabs. Mirrored in a right-to-left page. |
| `Down / Up` | The same in a vertical list. The off-axis pair is left to the page, so a horizontal list does not eat a scroll. |
| `Home / End` | The first and last tab. |
| `Enter, Space` | Chooses the focused tab. Only `activation="manual"` needs them; automatic activation has already chosen it. |

## Tabs accessibility

Implements [the WAI-ARIA APG pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).

- `role="tablist"` on the list, named by its own `label` — a page can hold several, and a tablist has no name of its own.
- `role="tab"` with `aria-selected` on each tab, and `aria-controls` naming the panel it shows — written only while that panel is mounted, since a dangling reference names nothing.
- `role="tabpanel"` on the panel, `aria-labelledby` its tab, and `tabindex="0"` so the content is reachable from the keyboard whether or not anything inside it is focusable.
- `aria-orientation` follows `orientation`, and the arrows follow the reading order: in a right-to-left page ArrowLeft is the *next* tab.
- The travelling indicator is `aria-hidden`: it says what `aria-selected` already says, and it keeps a `Highlight` fill in a forced-colors mode, so it is decoration in every mode that has colour.

Swept with axe on every release, in 3 states: `Tabs`, `Tabs (vertical, every panel mounted)`, `Tabs (travelling indicator, resizing panels)`. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.

## Tabs style tree

Every part the component draws is a node with a name, so a default can be restyled with `Box.components()` instead of a selector — and a variant is a name too.

`tabs`variants: vertical

`tabs.list`variants: vertical, sliding

`tabs.tab`variants: vertical, underline

`tabs.indicator`variants: vertical

`tabs.panels`variants: resizing

`tabs.panel`variants: hidden

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

