Dev view
Tabs
A switching control that exposes a flat set of mutually exclusive panels through a row (or column) of tab triggers. Used to chunk related content under a single surface so only one chunk is visible at a time.
When to use
Use
When chunking related content into 3–7 mutually exclusive panels under a single surface where the user picks one chunk at a time. Best for parallel views of the same subject (a settings page with "Account / Notifications / Billing" panels) or alternative presentations of the same data.
Avoid
For sequential steps that must complete in order — that is `Stepper` or a wizard pattern. For navigation between top-level pages — that is `SidebarNav` or breadcrumbs. For more than seven panels — split into a navigation hierarchy or a search interface; tab-row overflow is a recovery pattern, not a design target.
Versus related
- accordion
`Accordion` allows multiple panels open at once (or one at a time, but visually stacked); `Tabs` always show one panel and replace it on selection. Accordion preserves all panel labels on screen; Tabs surface only one panel's content.
- segmented-control
`SegmentedControl` switches between mutually exclusive *views of the same content* (e.g. "List / Grid" toggle); `Tabs` switches between *different content* under the same heading. Segmented controls tend to live above the content they control; tabs replace it.
- sidebar-nav
`SidebarNav` navigates between independent pages with their own URLs; `Tabs` swap inline content within one page. Vertical Tabs and SidebarNav can look similar — the distinguisher is whether selecting changes the URL.
Code anatomy
| Slot | Code slot | Semantic |
|---|---|---|
tablist | tablist | tablist |
tab | tab | tab |
indicator | indicator | presentational |
tabpanel | tabpanel | tabpanel |
Variants, properties, states
Variants
Structurally different versions of the component.
linecontainedpill Properties
The same component, parameterised.
| Property | Type |
|---|---|
orientation | horizontal | vertical |
activation | automatic | manual |
density | comfortable | compact |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | selectedbusylazyerror |
Cross-framework expression
| Framework | Structure mechanism | Variant mechanism |
|---|---|---|
| Web Components | A `<ui-tabs>` host element with named slots for `tablist` and content panels exposed through `<slot name="panel-{id}">` | attributes for variant / orientation / activation; `[selected]` attribute on tab elements drives styling |
| React | compound components (`Tabs.Root`, `Tabs.List`, `Tabs.Trigger`, `Tabs.Content`) à la Radix; or the React Aria `useTabList` hook with a collection | props on `Tabs.Root` (variant / orientation / activationMode); selection state as a controlled or uncontrolled value prop |
| Angular (signals) | Angular CDK A11y `cdk-tab-group` or a custom directive composition with content projection; signal-based selectedIndex | input<'line' | 'contained' | 'pill'>(), input<'horizontal' | 'vertical'>(), input<'automatic' | 'manual'>() |
| Vue | Headless UI `<TabGroup>` / `<TabList>` / `<Tab>` / `<TabPanels>` / `<TabPanel>` | defineProps with literal-union types; `:selected-index` for controlled selection |
Events
selectedChangetabActivate
Performance thresholds
tablistOverflowtab-count≥7tabsAbove ~7 tabs the tablist overflows the viewport on common desktop widths and the cognitive load of "remembering which tab has what" exceeds tab-list usefulness. Split into a navigation hierarchy or a search interface above this threshold; tab-row overflow with horizontal scroll is a recovery pattern, not a design target. Mirrors the existing whenToUse "≤7 panels" guidance.
lazyPanelRenderpanel-payload-size≥100kbTabpanels above ~100kb of rendered DOM payload should lazy-mount on selection rather than pre-render. Below the threshold, eager-render all panels — allows CSS-only show/hide and avoids per-selection mount latency. Above the threshold, the per-mount cost dominates the trade-off and the lazy-mount + aria-busy pattern (per the `tabs-lazy-panel-no-aria-busy` mistake) earns its complexity.
Accessibility
| Slot | Accessibility hint | |
|---|---|---|
tablist | `role="tablist"` with `aria-orientation` matching the visual orientation. If the tabs label a region elsewhere on the page, give the tablist an `aria-label` or `aria-labelledby`. | |
tab | `role="tab"`. Exactly one tab in the list has `aria-selected="true"` at any time. `aria-controls` references the associated tabpanel id. Disabled tabs use `aria-disabled` and remain focusable so keyboard users can still navigate to them. | |
indicator | Presentational only. Selection is communicated through `aria-selected` on the tab; the indicator is a visual reinforcement, not a substitute. | |
tabpanel | `role="tabpanel"` with `aria-labelledby` pointing at its tab. Make the panel programmatically focusable (`tabindex="0"`) only if the panel itself is the first interactive surface; otherwise let the first interactive child receive focus naturally. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab | Focus enters the tablist on the currently selected tab (roving tabindex — only the selected tab has `tabindex="0"`). One more Tab leaves the tablist toward the tabpanel or its first focusable descendant; further Tabs walk into and through the panel content. |
ArrowLeft / ArrowRight (horizontal) or ArrowUp / ArrowDown (vertical) | Moves focus to the previous/next tab inside the tablist. In `activation: automatic` mode the tab activates on focus (`aria-selected="true"`, panel swaps); in `activation: manual` mode focus moves but selection is unchanged. |
Home / End | Focus moves to the first/last tab in the tablist. Activation follows the active activation mode. |
Enter or Space (in manual mode) | Activates the focused tab — `aria-selected` flips, the panel renders. In automatic mode this is a no-op (already activated by focus). |
Screen-reader announcements
| Trigger | Expected |
|---|---|
| Focus enters a tab | SR announces "<label>, tab, selected" for the currently selected tab, or "<label>, tab" for non-selected tabs, followed by position ("1 of 3"). Position comes from the `aria-posinset` / `aria-setsize` pair when the tablist is large or generated. |
| Lazy panel mounts with `aria-busy="true"` | SR announces the busy state on the tabpanel; once `aria-busy="false"` and content lands, SR reads the panel content starting with the next focusable inside. |
| Selection changes via arrow keys (automatic mode) | Old panel is removed from the AT tree (or marked `aria-hidden="true"` if kept in DOM); new panel is announced via its `aria-labelledby` relationship to the newly selected tab. |
axe-core rules to assert
aria-required-childrenaria-required-parentaria-rolestabindexcolor-contrastfocus-order-semantics
Common mistakes
#tabs-no-arrow-keys
Tab key cycles through every tab instead of arrow keys
Implementation uses native focusable buttons with no roving `tabindex`, so Tab walks through every tab plus the panels below. Screen-reader users hear "tab 1 of N" announcements conflicting with the actual document order.
Implement the roving-tabindex pattern: only the selected tab has `tabindex="0"`, all other tabs have `tabindex="-1"`. Arrow keys (Left/Right for horizontal, Up/Down for vertical) move focus between tabs and update the roving tabindex.
#tabs-panel-not-labelled
Tabpanels missing `aria-labelledby` to their tab
The panel announces only its content with no relationship to the tab that controls it. Screen-reader users navigating the page out of order cannot identify which tab the panel belongs to.
Each tabpanel sets `aria-labelledby` to the id of its tab. Each tab sets `aria-controls` to the id of its panel.
#tabs-state-as-data-attribute-only
Selection driven only by a `data-selected` attribute, no `aria-selected`
Visual selection works (CSS targets the data attribute) but assistive tech reports every tab as unselected.
`aria-selected` is the source of truth. Style from `[aria-selected="true"]` rather than introducing a parallel `data-selected` attribute that has to stay in sync.
#tabs-disabled-but-not-skipped
Disabled tabs cannot receive keyboard focus
Setting `disabled` on a disabled tab removes it from the focus order entirely, breaking arrow-key navigation between the remaining tabs and stranding users on the disabled tab.
Use `aria-disabled="true"` instead of the HTML `disabled` attribute. The disabled tab remains focusable and announceable; activation (Enter / Space) is the operation that becomes a no-op.
#tabs-lazy-panel-no-aria-busy
Lazy-loaded tabpanel renders empty without an `aria-busy` announcement
Selecting a tab fetches its panel content asynchronously. The panel is mounted immediately but empty, so screen-reader users land on a tabpanel with no perceivable content and no signal that anything is loading.
Set `aria-busy="true"` on the tabpanel while content is in flight, render a visible loading affordance, and announce the transition (e.g. via `aria-live="polite"` on a status region inside the panel). Clear `aria-busy` when content is ready.
#tabs-overflow-no-scroll-into-view
Overflowing tablist does not scroll the focused tab into view
The tablist scrolls horizontally on overflow, but pressing arrow keys, Home, or End moves focus to a tab that remains offscreen. Sighted keyboard users lose track of focus; screen magnifier users see no movement.
On focus change inside the tablist (arrow keys, Home, End), call `element.scrollIntoView({ block: 'nearest', inline: 'nearest' })` on the newly focused tab, or compute the scroll offset manually so the focused tab is fully visible with a small inline padding.
#tabs-indicator-not-rtl-aware
Indicator animation hard-codes left-to-right movement
The selected-tab indicator animates by interpolating `left` / `transform: translateX(...)` from the previous tab to the next. Under `dir="rtl"` the indicator slides the wrong way or ends up offset from the active tab.
Compute the indicator position from the selected tab's bounding box (`getBoundingClientRect`) relative to the tablist, not from a directional offset. Use logical properties (`inset-inline-start`) or transform values that respect writing direction.
Figma↔Code mismatches
- 01 Figma
Each tab state (default / hover / selected / disabled) modeled as a separate Figma variant on the tab component
CodeA single tab element with `:hover`, `:focus-visible`, and `aria-selected` driving CSS
ConsequenceVariant explosion: 4 states × 3 sizes × 2 orientations = 24 variants, and developers cannot mechanically map a Figma "hover variant" to a CSS pseudo-class without manual translation.
CorrectStates belong on a separate states sheet, not as variants. The Figma component captures structural variants (line / contained / pill); the canonical reference documents the state matrix once.
- 02 Figma
The active-tab underline drawn as a separate decorative line component on the artboard
CodeAn indicator pseudo-element (or absolute-positioned div) whose position is bound to the selected tab
ConsequenceDesigners move the underline manually when switching the visible tab in mocks; developers wire the indicator to selection programmatically. The two artefacts disagree about which tab is active at any given mock.
CorrectDocument that the indicator is presentational and *derived from* the selected tab. In Figma, bind the indicator's position to the selected variant; in code, animate the indicator from the selected tab's bounding box.
- 03 Figma
Vertical tabs drawn as a left sidebar with no semantic relationship to the right-hand content
CodeVertical tabs use the same `role="tablist"` with `aria-orientation="vertical"` and arrow keys reverse from horizontal to vertical
ConsequenceDesigners and developers diverge on whether a vertical layout is "tabs" or a "sidebar nav". The keyboard model and the announced role differ between the two patterns.
CorrectTreat vertical tabs as the same component with an `orientation` property. Reserve "sidebar nav" as a distinct pattern with different keyboard semantics (Tab moves between links, no arrow cycling).
- 04 Figma
Manual vs. automatic activation modeled as two visually identical Figma components
CodeA property toggles whether arrow-key navigation activates the tab on focus (automatic) or only on Enter / Space (manual)
ConsequenceDesigners may not realise activation mode is a meaningful decision. Developers default to one mode without knowing the other exists; users with assistive tech feel the difference.
CorrectDocument `activation` as a first-class property in the canonical reference. Default is automatic for tabs that swap cheap, static content; manual for tabs that load expensive content per panel.
- 05 Figma
A tablist that overflows the available width drawn either as wrapped onto a second row or simply truncated at the edge
CodeOverflowing tablists scroll horizontally with edge-fade affordances and Home / End / arrow keys that scroll the focused tab into view
ConsequenceDesigners either invent a wrapping pattern that breaks the single-row keyboard model, or hide the overflow with no affordance, leaving developers to invent the scroll behavior ad hoc. Keyboard users on overflowing tablists land on tabs that are visually offscreen.
CorrectStandardise horizontal scroll for overflowing tablists with edge-fade affordances and explicit "scroll focused tab into view" behaviour on Home / End / arrow navigation. Reserve wrapping for explicit multi-row layouts (rare; usually a sign the structure should be rethought).