Tabs
One region, several panels, one visible at a time — chosen from a tab list that is a single tab stop with arrow-key movement, per the APG tabs pattern.
Basic usage
Install the package:
pnpm add @design-schema/react @design-schema/tokens Import the stylesheet once for the app, then the component:
import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Tabs } from '@design-schema/react'; Examples
import type { ReactElement } from 'react';
import { Tabs, TabPanel, type TabsItem } from '@design-schema/react';
const TABS: TabsItem[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'activity', label: 'Activity', badge: '3' },
{ id: 'files', label: 'Files', icon: 'external' },
{ id: 'settings', label: 'Settings', disabled: true },
];
function panelsFor(tabs: TabsItem[]): ReactElement[] {
return tabs.map((tab) => (
<TabPanel key={tab.id} id={tab.id}>
{tab.label} panel.
</TabPanel>
));
}
<Tabs tabs={TABS} label="Project sections">{panelsFor(TABS)}</Tabs>Props
9 rows| Prop | Type | Default | Description |
|---|---|---|---|
| tabs required | array { id: string; label: string; icon?: IconName | undefined; disabled?: boolean | undefined; badge?: string | undefined }[] | — | The tabs in order. `badge` is a short count or status shown after the label ("3", "New"). The icon is an Icon at `size: md` (the label's size) in the tab's current foreground color (currentColor on web/Lit, the Icon `color` prop on RN). Optional fields also accept an explicit `undefined`; the exported item type is `TabsItem`, and a package that actually shipped `TabsTab` keeps it as a deprecated alias of `TabsItem` rather than dropping it — a package that never exported it adds nothing (Lit is the only one that had it). The alias is a type, erased at runtime, so it carries the `@deprecated` tag alone and no development warning is possible. An `id` must be a valid IDREF token (no whitespace), since tab and panel element ids are built from it; the component does not sanitize it. |
| children required | content | — | One panel per tab, in the same order, each wrapped in the exported `TabPanel` (or `<ds-tab-panel>`) with a matching `id`. Only the selected panel is rendered unless `keepMounted`. Examples describe children in words; stories and tests build them from `args.tabs` (one TabPanel per entry, id = the tab id), so a scenario that replaces `tabs` also replaces the panels and has no orphans. |
| label required | string | — | Accessible name of the tab list ("Account sections"). Not shown visually. |
| value | string | — | Controlled selected tab id. Omit for uncontrolled. |
| defaultValue | string | — | Initially selected tab id. Defaults to the first enabled tab. Taken verbatim, never corrected: one naming a disabled tab selects it and shows its panel, one matching no tab selects nothing — in both cases the roving tab stop falls back to the first enabled tab, and neither warns. |
| activation | automatic | manual | automatic | `automatic` selects a tab as arrow keys move to it (fine when panels are cheap); `manual` moves focus only and selects on Enter/Space (use when a panel loads data). |
| orientation | horizontal | vertical | horizontal | Vertical tab lists sit beside their panels and use Up/Down arrows. |
| fit | start | fill | start | `start` packs tabs at the start; `fill` stretches them across the width (phones, two to four tabs). Horizontal only: vertical tabs always span the list's inline size, so `fill` has no effect when vertical. A `fill` list does not scroll — it is for the few tabs that fit — so tabs that overflow it are simply squeezed, and keeping the selected tab in view is a no-op there. |
| keepMounted | boolean | false | Keep unselected panels in the tree (hidden) so their state survives switching. No effect on Lit, where panels are the consumer's light-DOM children and are always kept (hidden); the property exists there for parity and is not reflected. |
Events
1 row| Event | Type | Description |
|---|---|---|
| onChange | onChange / change | Fired when the selected tab changes, with the new id. |
Accessibility
Decisions this component leaves to you
Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.
Implicit ARIA role: tablist.
Anatomy
What generation guarantees
Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.
Selected state — Selection is exposed programmatically (`aria-selected` / `aria-checked`).
Arrow navigation — Arrow keys move between items; Home and End jump to the ends.
Roving tabindex — The group is a single tab stop; the arrow keys move within it.
Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.
Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.
Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.
Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).
Reduced motion — Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.
Under `prefers-reduced-motion: reduce` the transitions are dropped rather than shortened, and the component still reaches every state.