Tabs
Panels that swap in place, without navigating.
Why it exists, and why FilterBar does
Azalea once shipped a Tabs that was marked up with role="tablist" and was actually a row of links: ?status=DEAD loaded a different page. A screen reader announced “tab, 1 of 5, selected” for a link and promised arrow-key navigation that did not exist. That component became FilterBar, a nav of links — which is what it always was.
The rule
These ones pay for the role
Arrow keys move between tabs, Home and End jump to the ends, and exactly one tab sits in the tab order — so Tab moves past the strip into the panel rather than through every tab in it. Click into the strip and try the arrows.
The payload as it arrived, headers included.
What the provider sent back — status, body, and how long it took.
Seven delivery events, oldest first.
Panels are hidden, not unmounted
A panel that unmounts loses its scroll position, any half-typed input in it, and — where the panels are the same data in two shapes — makes switching back a re-fetch. hidden costs a little more markup and none of that.
Now go back. The text is still there — the panel was hidden, not destroyed.
In both themes
light
Panel content.
The other panel.
dark
Panel content.
The other panel.
Guidance
Use it when
- Alternate views of one record: request and response, raw and rendered, summary and detail.
- Two to five panels. Past that the strip scrolls and the tabs stop being visible at once, which is the only thing tabs are for.
- Content that is already loaded — activation follows focus, so switching must cost nothing.
Reach for something else when
- Anything that changes the URL. That is FilterBar, and marking navigation as tabs is the exact bug this component's existence documents.
- A form split across panels. A validation error in a hidden panel is an error nobody can see.
- One tab. A tablist of one is a heading.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| Tabs value | string | — | Controlled selection. Omit to let Tabs own it. |
| Tabs defaultValue | string | — | The tab selected on first render. |
| Tabs onValueChange | (value: string) => void | — | Fires on every change, controlled or not. |
| TabList label | string | — | Required. A tablist with no name announces as “tab list”. |
| Tab value | string | — | Matches the TabPanel it controls. |
| Tab count | number | — | A count beside the label, spaced the same way everywhere. |
Accessibility
Roving tabindex, arrow keys with wrapping, Home and End, and aria-controls / aria-labelledby pointing at each other so a screen reader can move between a tab and its panel. The panel is focusable, so Tab from the strip lands on the content the user just chose.
Activation follows focus — arrowing to a tab selects it — which is correct here only because every panel is already rendered. A tab that triggers a fetch should use manual activation instead, and this component is not that.
tests/accessibility.test.ts asserts every one of those, including that FilterBar still does not claim the role. The two components exist as a pair, and the test is what keeps them from converging again.