Components / disclosure
Tabs
Radix is permitted here — Tabs is on the closed list of six.
Summary
Alternative views of one thing, one visible at a time.
| APG pattern | Tabs |
| Built on | Radix |
| Status | stable |
| Since | 0.1.0 |
<Tabs label="Project view" activation="automatic" tabs={[{ id: 'edit', label: 'Edit', content: … }]} />
Anatomy
A tablist of tabs over one visible panel.
| Part | Element | Required |
|---|---|---|
label | <span> | yes |
list | <radixtabs.list> | yes |
panel | <radixtabs.content> | yes |
tab | <radixtabs.trigger> | yes |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
tabs | TabItem[] | required | The tabs and their panels, in display order. Each needs a stable id. |
value | string | none | The selected tab. Passing it makes the set controlled — which is what you want if the selection belongs in the URL. |
defaultValue | string | none | Which tab starts selected. Defaults to the first. |
onValueChange | (value: string) => void | none | Called with the newly selected tab id. |
activation | automatic · manual | automatic | automatic selects on focus — right when panels are cheap. manual waits for Enter or Space, so arrowing past tabs does not fire a request each time. |
orientation | horizontal · vertical | horizontal | Which way the tablist runs. vertical stacks the tabs into a column beside the panel, moves the indicator to each tab's inline end, and Up and Down move between tabs instead of Left and Right. |
label | string | none | Names the tab list. Required when a page has more than one set. |
labelledBy | string | none | Id of a visible element that names the tab list. Wins over label. |
lazy | boolean | false | Mount each panel only while it is selected. Cheaper for expensive panels, but the unselected tabs' aria-controls then point at nothing. |
States
| State | Expressed as | |
|---|---|---|
| Disabled | :disabled | |
| Focused | :focus-visible | |
| Hover | :hover | |
| Selected | aria-selected | |
| Open or closed | data-state |
Behavior
automatic selects a tab as soon as focus lands on it — correct when panels are already loaded. Switch to manual when opening a panel costs a request, so arrowing across four tabs does not fire four loads.
Arrow keys move between tabs; the set is one tab stop.
orientation="vertical" stacks the list into a column beside the panel. The indicator moves to each tab's inline end, sitting on the same shared hairline the underline sits on horizontally, and movement passes to Up and Down. A CSS-only consumer writes data-orientation="vertical" on the root and nothing deeper — the selectors reach no further.
Accessibility
The tablist is named, so a page with two sets of tabs has two distinguishable ones.
Radix parks the tab stop on the tablist root rather than on an item, which satisfies the one-tab-stop requirement differently from the APG's own example. The port asserts the invariant rather than the number.
Keyboard
| Key | Result |
|---|---|
Arrow keys | Move between tabs — Left and Right horizontally, Up and Down when orientation="vertical". With activation="automatic" the panel changes as focus lands |
Enter / Space | Selects the focused tab, which is what activation="manual" waits for |
Home / End | Jump to the first or last tab |
Tab | Leaves the tablist and enters the panel |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-label | set at render |
aria-labelledby | set at render |
Focus
The tablist is one stop. Radix parks it on the tablist root rather than on a tab, which satisfies the one-stop requirement differently from the APG's own example — the port asserts the invariant rather than the number.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It takes the touch floor under (pointer: coarse) — the visual size is unchanged on a desktop pointer.
Tested
test/apg/partials-sweep.test.jsx
Known gaps
_No upstream requirement is recorded as not applicable._
Appearance
| Token | Used for |
|---|---|
--se-accent | border-block-end-color, border-inline-end-color |
--se-border | border-block-end, border-inline-end |
--se-border-width | border-block-end, border-inline-end, margin-block-end, margin-inline-end |
--se-control-h | min-block-size |
--se-duration-instant | transition |
--se-ease-standard | transition |
--se-focus-color | outline |
--se-focus-offset | outline-offset |
--se-focus-width | border-block-end, border-inline-end, outline |
--se-icon-sm | block-size, inline-size |
--se-radius-sm | border-radius |
--se-space-1 | gap |
--se-space-2 | gap, padding-block-end |
--se-space-3 | padding-inline-end |
--se-space-4 | gap |
--se-target-min | min-block-size |
--se-text | color |
--se-text-disabled | color |
--se-text-muted | color |
Motion
The active tab's indicator crosses --se-duration-fast. Panels do not animate: a panel that slides in delays the content the reader just asked for.
Content
Tab labels are nouns, short, and parallel — Edit, Preview, Settings. One long label among three short ones makes the set look broken.
Usage
Tabs are not navigation. If the panels are really pages, or the reader should be able to link to one or use the back button, use links and routes. Tabs hide their content from search and from anyone you send the URL to.
Use them for alternatives, not for sequence. A wizard needs a visible sense of progress, which tabs deliberately do not give.
Notes
Radix is permitted here — Tabs is on the closed list of six.