Skip to content

Tabs

  • Settings split into Account, Billing and Security
  • Views of one item: Overview, Activity, Files
  • Content where people look at one part at a time

Not when people need to compare panels — show them together — or for steps that must be done in order.

Choosing between organising content:

ComponentUse it forFor example
AccordionSections stacked on one page, of which people read one or a fewFAQ, settings groups, order details
TabsViews of the same thing at the same level, one at a timeAccount / Billing / Security
CardA self-contained group of content, shown as one unit, often in a grid or listA product tile, a project summary
TableRows of records compared across the same columnsInvoices, users, order lines
SeparatorA line that divides sections, with no content of its ownBetween menu groups or toolbar groups

Choosing between moving around:

ComponentUse it forFor example
BreadcrumbShowing where the page sits in a hierarchy, with links up itHome / Projects / Website
PaginationMoving between pages of one long listSearch results, page 3 of 12
TabsSwitching between views within one pageOverview / Activity / Settings
LinkOne destination“Read the docs”

Tabs shows one panel of content at a time, chosen from a list of tabs. It follows the WAI-ARIA tabs pattern: the whole list is a single Tab stop, and the arrow keys move between tabs.

Name, email and avatar.
import { Tabs } from '@arun-dev/ui';
export default function TabsBasics() {
return (
<Tabs.Root defaultValue="account" style={{ inlineSize: '100%' }}>
<Tabs.List aria-label="Settings">
<Tabs.Tab value="account">Account</Tabs.Tab>
<Tabs.Tab value="billing">Billing</Tabs.Tab>
<Tabs.Tab value="team" disabled>
Team
</Tabs.Tab>
<Tabs.Tab value="security">Security</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="account">Name, email and avatar.</Tabs.Panel>
<Tabs.Panel value="billing">Plan, invoices and payment method.</Tabs.Panel>
<Tabs.Panel value="team">Members and roles.</Tabs.Panel>
<Tabs.Panel value="security">Password and two-factor authentication.</Tabs.Panel>
</Tabs.Root>
);
}
import { Tabs } from '@arun-dev/ui';
<Tabs.Root defaultValue="account">
<Tabs.List aria-label="Settings">
<Tabs.Tab value="account" />
<Tabs.Tab value="billing" />
</Tabs.List>
<Tabs.Panel value="account" />
<Tabs.Panel value="billing" />
</Tabs.Root>;
PartRendersDoes
Root<div>holds the selected value, orientation, activationMode
List<div role="tablist">the arrow keys
Tab<button role="tab">selects its panel; value pairs it with one
Panel<div role="tabpanel">the content, labelled by its tab, hidden while unselected

A Tab and its Panel share a value; the ids that wire them together are generated. Name the List with aria-label when there is more than one set of tabs on the page.

KeyDoes
Tabenters the list at the selected tab, then moves on to its panel
→ ← (horizontal), ↓ ↑ (vertical)the next or previous tab, wrapping at the ends
Home, Endthe first or last tab
Enter, Spaceselects the focused tab (with activationMode="manual")

Disabled tabs are skipped. In a right-to-left layout the arrows follow what you see: the next tab sits to the left, so ← moves to it.

With activationMode="manual", set focusableWhenDisabled on the Root to keep disabled tabs in the arrow-key sequence, so a screen reader announces them as unavailable. They still can’t be selected. Under "automatic" it has no effect: moving focus to a tab selects it, and a disabled tab must never be selected.

With activationMode="automatic" (the default), moving focus to a tab selects it, so the arrow keys change the panel as they go. Use "manual" when a panel is slow to show — loaded from a server, say — so the arrows only move focus and Enter or Space selects.

Language, time zone and theme.
import { Tabs } from '@arun-dev/ui';
export default function TabsVertical() {
return (
// Vertical: Up and Down move between tabs. Manual: they only move focus, and Enter or
// Space selects — for panels that are slow to show.
<Tabs.Root
defaultValue="general"
orientation="vertical"
activationMode="manual"
style={{ inlineSize: '100%' }}
>
<Tabs.List aria-label="Preferences">
<Tabs.Tab value="general">General</Tabs.Tab>
<Tabs.Tab value="notifications">Notifications</Tabs.Tab>
<Tabs.Tab value="privacy">Privacy</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">Language, time zone and theme.</Tabs.Panel>
<Tabs.Panel value="notifications">Email and push notifications.</Tabs.Panel>
<Tabs.Panel value="privacy">Who can see your profile.</Tabs.Panel>
</Tabs.Root>
);
}

This one is also vertical: orientation="vertical" puts the list beside the panels, and ↓ ↑ move between tabs.

position sets which side of the panels the list sits on, along the axis orientation chooses. It is logical, so end swaps sides in a right-to-left layout.

orientationpositionList (wide)Panel (wide)List (narrow)Panel (narrow)
horizontalstart (default)abovebelow the listabove, scrollsbelow
horizontalendbelowabove the listbelow, scrollsabove
verticalstart (default)leftrightstacked above, full widthbelow
verticalendrightleftstacked below, full widthabove
  • A horizontal list never wraps: when the tabs outgrow the row it scrolls sideways.
  • A vertical list stacks under about 30rem. The test is the width of the Tabs.Root, not the viewport’s, so tabs in a narrow sidebar stack on a wide screen too. It is still a vertical list: ↓ ↑ still move between tabs. Give a vertical Tabs.Root a width (a block in normal flow has one) — it is a size container, so it would collapse to nothing in a shrink-to-fit parent.
  • The list stays first in the DOM, so Tab and screen readers reach it before the panels even when position="end" draws it last — the same as a bottom navigation bar.
Name, email and avatar.

Click a tab, then use the arrow keys: with automatic each arrow selects the tab it reaches; with manual it only moves focus, and Enter or Space selects. Drag the frame's bottom-right corner to narrow it.

<Tabs.Root
  orientation="horizontal"
  position="start"
  activationMode="automatic"
>
  <Tabs.List /> …
</Tabs.Root>
Tabs.Root props
PropTypeDefaultDescription
valuestringControlled selection: the value of the selected Tab. Provide onValueChange alongside it. Never pass undefined — the mode is fixed at mount.
defaultValuestringInitial selection when uncontrolled. Read once, at mount. Without one, no tab is selected.
onValueChange(value: string) => voidCalled with the Tab's value whenever the selection changes.
orientation'horizontal' | 'vertical''horizontal'The axis the tabs run along, and so which arrow keys move between them.
position'start' | 'end''start'Which side of the panels the list sits on, along the orientation axis: start is above (horizontal) or before (vertical), end is below or after. Logical, so it swaps sides in a right-to-left layout. The list stays first in the DOM.
activationMode'automatic' | 'manual''automatic'automatic selects a tab as soon as it has focus; manual waits for Enter, Space or a click.
focusableWhenDisabledbooleanfalseKeeps disabled tabs in the arrow-key sequence, so a screen reader announces them as unavailable; they still cannot be selected. Only with activationMode="manual" — under "automatic" moving focus selects, so disabled tabs are always skipped.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Tabs.Tab props
PropTypeDefaultDescription
valuestringIdentifies this tab, and the Panel with the same value. Required.
disabledbooleanfalseCannot be selected. Skipped by the arrow keys, unless the Root sets focusableWhenDisabled with manual activation.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
Tabs.Panel props
PropTypeDefaultDescription
valuestringThe value of the Tab this panel belongs to. Required.
classNamestringConcatenated with the component's own classes, never replacing them.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.

Tabs.List takes the props of the <div> it renders, plus render and ref.

  • Panels stay mounted while hidden, so a form inside keeps what was typed between visits.
  • Pass a defaultValue or value: without one, no tab starts selected, and the first enabled tab takes the Tab stop.
  • Every part takes its element’s own events — onClick on a Tab, onKeyDown on the List.
  • Restyle every set of tabs with the --tabs-* tokens, e.g. --tabs-indicator-color.