Azalea

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

If the content is a different query — a filtered list, another record — that is navigation, and FilterBar is the honest markup for it. If the panels are alternate views of the same thing and the URL should not change, that is tabs, and tabs owe you the keyboard behaviour.

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.

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.

In both themes

light

Panel content.

dark

Panel content.

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

PropTypeDefaultDescription
Tabs valuestringControlled selection. Omit to let Tabs own it.
Tabs defaultValuestringThe tab selected on first render.
Tabs onValueChange(value: string) => voidFires on every change, controlled or not.
TabList labelstringRequired. A tablist with no name announces as “tab list”.
Tab valuestringMatches the TabPanel it controls.
Tab countnumberA 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.