# Tabs

> Tablist navigation with keyboard support, a sliding selection indicator, and per-item icon/count.

- Status: stable
- Group: navigation
- Page: https://www.digitaltableteur.com/design-system/components/tabs
- Contract: https://www.digitaltableteur.com/contracts/v1/Tabs.contract.json

## When to use

Tabs renders the tablist only; the parent owns the panels, pairing each to its tab with the exported `getTabPanelProps(key, isActive)` helper (or matching id/aria-labelledby/hidden attributes by hand). Drive it controlled with `activeTab` + `onTabChange`, or uncontrolled with `defaultActiveTab`. Arrow keys and Home/End move focus between tabs (wrapping at the ends) and Enter or Space selects the focused tab; arrows and Home/End skip disabled tabs; `variant` picks the visual language (default, pills, underline) and `size` the density. Tabs switch views in place; they are not page navigation.

## When not to use

- Do not nest tablists inside another tablist. APG explicitly disallows it; keyboard navigation becomes ambiguous.
- Do not use Tabs for non-mutually-exclusive content. Two panels open at once is a job for **Accordion** (in a future `mode="multi"`) or side-by-side layout.
- Do not use tabs for page navigation; that is NavLink in a nav landmark; tabs switch views inside one page.

## Look-alikes

- Accordion: Do not use Tabs for non-mutually-exclusive content. Two panels open at once is a job for **Accordion** (in a future `mode="multi"`) or side-by-side layout.
- FilterChip: Do not use FilterChip as navigation between content panels; use Tabs.
- NavLink: Do not use tabs for page navigation; that is NavLink in a nav landmark; tabs switch views inside one page.

## Props

| Prop | Type | Default | Description | Required |
| --- | --- | --- | --- | --- |
| `activeTab` | `string` | none | Active tab key (controlled mode); "" is treated as absent. | No |
| `defaultActiveTab` | `string` | none | Initially active tab key (uncontrolled mode). | No |
| `size` | `"sm" \| "md" \| "lg"` | `md` | Size. Defaults to `md`. | No |
| `tabs` | `TabItem[]` | none | Array of tab items with key, label, optional disabled/icon/count. | Yes |
| `onTabChange` | `(key: string) => void` | none | Called with the clicked tab's key. | No |
| `className` | `string` | `""` | Optional utility classes on the tablist. | No |
| `ariaLabel` | `string` | Navigate between tabs | Accessible label for the tablist. Defaults to the translated "Navigate between tabs" (also used when an empty string is passed). | No |
| `variant` | `"pills" \| "underline" \| "default"` | `default` | Visual style variant. Defaults to `default`. | No |
| `idPrefix` | `string` | none | Prefix for the tab and panel ids (prefix-tab-key, prefix-tabpanel-key) so two Tabs on one page can share keys. Pass the same value to getTabPanelProps. Omit it for the unprefixed tab-key ids. | No |

## Variants

- Available variants: `default`, `pills`, `underline` (default `default`).
- Available sizes: `sm`, `md`, `lg` (default `md`).

## Invalid combinations

- MUST NOT: `activeTab` and `defaultActiveTab` set together. `activeTab` makes the component controlled and `defaultActiveTab` uncontrolled; pass one, not both.

## Keyboard

| Key | Behaviour |
| --- | --- |
| ArrowLeft | Moves focus or the value in that direction. (standard) |
| ArrowRight | Moves focus or the value in that direction. (standard) |
| Home | Moves to the first item. (standard) |
| End | Moves to the last item. (standard) |
| ArrowUp | Moves focus or the value in that direction. (standard) |
| ArrowDown | Moves focus or the value in that direction. (standard) |
| Enter | Activates the focused control. (standard) |
| Space | Activates or toggles the focused control. (standard) |

Rows marked (standard) name a key the contract lists without a description; the behaviour shown is the usual one for that key.

## Rules

Every item under When not to use is a MUST NOT. MUST and SHOULD follow RFC 2119.

| Level | Rule | How it is checked |
| --- | --- | --- |
| MUST NOT | `activeTab` and `defaultActiveTab` set together. `activeTab` makes the component controlled and `defaultActiveTab` uncontrolled; pass one, not both. | Checked by the MCP tool validate_component_usage. |
| MUST | ARIA: Tablist. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Tab. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Aria-selected. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Aria-controls. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Manual activation: arrows and Home/End move focus (wrapping), Enter or Space selects the focused tab; only the active tab is in the Tab order. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Arrows, Home and End skip disabled tabs, so every enabled tab is reachable with the keyboard. | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | ARIA: Tab and panel ids are tab-<key> and tabpanel-<key>, or <idPrefix>-tab-<key> and <idPrefix>-tabpanel-<key> when idPrefix is set; two Tabs on one page that share keys need distinct idPrefix values (a development warning names a duplicate id) | Not yet verified: no evidence record. WCAG 4.1.2. |
| MUST | Required stories produce no axe violations at the configured severity. | Automated by `npm run test:stories:matrix:ci`. WCAG 2.5.8. |
| MUST | The accessibility tree (role, name, state) matches the committed snapshot for every required story. | Automated by `npm run test:stories:matrix:ci`. WCAG 1.3.1, 4.1.2. |
| MUST | The component remains legible and operable under a real browser forced-colors: active pass. | Automated by `npm run test:stories:hc`. |
| MUST | Every documented keyboard interaction works as specified. | Not yet verified: no evidence record. WCAG 2.1.1, 2.1.2. |
| MUST | A human completed an end-to-end accessibility review of this component. | Manual review, last on 2026-11-04. |
| SHOULD | Render one tabpanel per tab and wire it with getTabPanelProps so aria-controls/aria-labelledby stay consistent. | Not stated in the contract. |
| SHOULD | Use controlled mode (activeTab + onTabChange) when the active tab should sync with URL or app state. | Not stated in the contract. |
| SHOULD | Keep tab labels to one or two words; tabs are a switch, not a table of contents. | Not stated in the contract. |
| SHOULD | Disable a tab with disabled on its item instead of removing it, so the set stays stable while unavailable. | Not stated in the contract. |
| SHOULD NOT | Do not lazy-delete panel DOM for inactive tabs that hold form state; hide them (hidden attr) so user input survives switching. | Not stated in the contract. |
| MUST NOT | Maintainers: Do not hand-author `id="tab-<key>"` / `id="tabpanel-<key>"` on the panel side. Use `getTabPanelProps`; the id convention is internal. When two Tabs on one page share tab keys, give each a distinct `idPrefix` and pass it to `getTabPanelProps(key, isActive, idPrefix)`. | Release review; not machine-checked. |

## Install and import

- Install: `npm install @digitaltableteur/react` (version 0.9.1, restricted access on npm).
- Import: `import { Tabs } from "@digitaltableteur/react";`
- Entry point: `@digitaltableteur/react/navigation` exports it too.
- Styles: import `@digitaltableteur/react/style.css` once at the app boundary.
- Also available as the custom element `<dt-tabs>`; see the custom elements manifest.

## Links

- Machine-readable contract (Design System Contract 1.0): https://www.digitaltableteur.com/contracts/v1/Tabs.contract.json
- Markdown version of this page: https://www.digitaltableteur.com/design-system/components/tabs.md
- Component index as JSON: https://www.digitaltableteur.com/design-system/components.json
- All components: https://www.digitaltableteur.com/design-system/components
- Source: The implementation lives in a private repository. This page, its Markdown twin and the contract are generated from it at build time and are the public source of truth.
