Tabs
When to use
Section titled “When to use”- 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:
| Component | Use it for | For example |
|---|---|---|
| Accordion | Sections stacked on one page, of which people read one or a few | FAQ, settings groups, order details |
| Tabs | Views of the same thing at the same level, one at a time | Account / Billing / Security |
| Card | A self-contained group of content, shown as one unit, often in a grid or list | A product tile, a project summary |
| Table | Rows of records compared across the same columns | Invoices, users, order lines |
| Separator | A line that divides sections, with no content of its own | Between menu groups or toolbar groups |
Choosing between moving around:
| Component | Use it for | For example |
|---|---|---|
| Breadcrumb | Showing where the page sits in a hierarchy, with links up it | Home / Projects / Website |
| Pagination | Moving between pages of one long list | Search results, page 3 of 12 |
| Tabs | Switching between views within one page | Overview / Activity / Settings |
| Link | One 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.
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> );}Anatomy
Section titled “Anatomy”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>;| Part | Renders | Does |
|---|---|---|
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.
Keyboard
Section titled “Keyboard”| Key | Does |
|---|---|
| Tab | enters the list at the selected tab, then moves on to its panel |
| → ← (horizontal), ↓ ↑ (vertical) | the next or previous tab, wrapping at the ends |
| Home, End | the first or last tab |
| Enter, Space | selects 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.
Automatic or manual
Section titled “Automatic or manual”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.
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
Section titled “Position”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.
orientation | position | List (wide) | Panel (wide) | List (narrow) | Panel (narrow) |
|---|---|---|---|---|---|
horizontal | start (default) | above | below the list | above, scrolls | below |
horizontal | end | below | above the list | below, scrolls | above |
vertical | start (default) | left | right | stacked above, full width | below |
vertical | end | right | left | stacked below, full width | above |
- 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 verticalTabs.Roota 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.
Playground
Section titled “Playground”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
Section titled “Tabs.Root”| Prop | Type | Default | Description |
|---|---|---|---|
value | string | Controlled selection: the value of the selected Tab. Provide onValueChange alongside it. Never pass undefined — the mode is fixed at mount. | |
defaultValue | string | Initial selection when uncontrolled. Read once, at mount. Without one, no tab is selected. | |
onValueChange | (value: string) => void | Called 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. |
focusableWhenDisabled | boolean | false | Keeps 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. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Tabs.Tab
Section titled “Tabs.Tab”| Prop | Type | Default | Description |
|---|---|---|---|
value | string | Identifies this tab, and the Panel with the same value. Required. | |
disabled | boolean | false | Cannot be selected. Skipped by the arrow keys, unless the Root sets focusableWhenDisabled with manual activation. |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM. |
Tabs.Panel
Section titled “Tabs.Panel”| Prop | Type | Default | Description |
|---|---|---|---|
value | string | The value of the Tab this panel belongs to. Required. | |
className | string | Concatenated with the component's own classes, never replacing them. | |
render | ReactElement | Element or component to render instead of the default. Props, className, event handlers and ref are merged onto it. | |
ref | Ref<HTMLElement> | Ref to the rendered element. Merged with any ref on the render element. | |
…rest | HTMLAttributes | Anything 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
defaultValueorvalue: without one, no tab starts selected, and the first enabled tab takes the Tab stop. - Every part takes its element’s own events —
onClickon a Tab,onKeyDownon the List. - Restyle every set of tabs with the
--tabs-*tokens, e.g.--tabs-indicator-color.