Bridge 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.

  • disclosure

    `Disclosure` toggles a single panel open or closed in place. `Tabs` switches between several mutually exclusive panels under one surface. A page with one collapsible section is a Disclosure; a page that picks among 3–7 is Tabs.

  • stepper

    `Stepper` is sequential — steps are completed in a defined order with state carried between them (linear or non-linear). `Tabs` is parallel — peer panels switchable in any order, each independent of the others. Reach for Stepper when "step 3 needs the answer from step 1" is meaningful; reach for Tabs when the panels could be read in any order.

  • breadcrumbs

    `Breadcrumbs` traverses parent pages of a hierarchy (Home → Settings → Account); `Tabs` switches between sibling sections of the same page (overview / activity / files for a single project). Breadcrumbs cross contexts (URL changes); Tabs operate within a single context (URL stays the same). The decision test: does activating the affordance change the page (breadcrumbs) or change a section of the current page (tabs)?

  • pagination

    `Pagination` traverses pages of one logical list (results 1-20 / 21-40 / 41-60); `Tabs` switches between sibling sections of the same logical page (overview / activity / files for one project). Tabs surface different content under the same heading; pagination moves the same content's window across the dataset. Tabs and pagination commonly co-occur — a tabbed page with a paginated table inside one tab is canonical composition.

Tabs partition a region of content into named panels with a tablist trigger row — the canonical APG tab pattern. Three variants (line, contained, pill) cover the visual emphasis spectrum; orientation toggles between horizontal and vertical; activation switches between automatic (focus selects) and manual (focus then activate). The reference documents the roving-tabindex contract that makes the tablist a single keyboard stop, the aria-selected source-of-truth rule, the lazy-panel state graph for async content loading, and the overflow patterns that survive narrow viewports.

Highlight
Fig 1.1 · Tabs · Bridge view

Implementations

How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.

cdk MatTabGroup / MatTab (from @angular/material/tabs)
app.component.ts
import { Component, signal } from '@angular/core';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTabChangeEvent } from '@angular/material/tabs';
@Component({
selector: 'app-demo',
standalone: true,
imports: [MatTabsModule],
template: `
<mat-tab-group
[selectedIndex]="selectedIndex()"
(selectedTabChange)="onTabChange($event)"
(focusChange)="onFocusChange($event)"
headerPosition="above"
[animationDuration]="{ body: '300ms', header: '200ms' }"
[preserveContent]="false"
[stretchTabs]="false"
ariaLabel="Project sections"
>
<mat-tab label="Overview">
<p>Project overview content.</p>
</mat-tab>
<mat-tab>
<ng-template matTabLabel>
<mat-icon>notifications</mat-icon> Activity
</ng-template>
<ng-template matTabContent>
<!-- lazy-loaded: mounted only when tab is first selected -->
<p>Activity feed content.</p>
</ng-template>
</mat-tab>
<mat-tab label="Settings" [disabled]="true">
<p>Settings content.</p>
</mat-tab>
</mat-tab-group>
`,
})
export class AppDemoComponent {
selectedIndex = signal(0);
onTabChange(event: MatTabChangeEvent): void {
this.selectedIndex.set(event.index);
}
onFocusChange(event: MatTabChangeEvent): void {
// fires on arrow-key navigation without committing selection
}
}

Divergence

From Type → To Rationale
anatomy[tablist] reshaped Monolithic <mat-tab-group> compound component — no separately composed tablist element. The tablist role is rendered internally by MatPaginatedTabHeader; consumers have no direct access to it for slot composition. Angular Material ships a batteries-included compound component (<mat-tab-group> wrapping a managed MatTabHeader internally) rather than the canonical primitive-per-slot decomposition. Consumers project tab definitions as <mat-tab> children; the library emits the tablist markup. This collapses the canonical "compose tablist yourself" model into a single host element, trading composition flexibility for reduced boilerplate. Source: github.com/angular/components blob src/material/tabs/tab-group.ts (fetched 2026-05-04).
anatomy[tab] renamed <mat-tab> directive — label projected via string `label` @Input() or <ng-template matTabLabel> for rich content; body content via default ng-content or <ng-template matTabContent> for lazy mounting Canonical `tab` slot is a trigger element carrying role="tab". Material splits label from content within the same <mat-tab> declaration — matTabLabel provides the trigger label, default ng-content provides eager body, matTabContent provides lazy body. The trigger itself is rendered by the internal MatTabHeader; consumers never author an explicit role="tab" element. Source: github.com/angular/components blob src/material/tabs/tab.ts (fetched 2026-05-04).
anatomy[indicator] renamed Ink-bar (internal MatInkBar); configured via `fitInkBarToContent` @Input() on MatTabGroup (boolean, default false — full-width bar; true — bar width matches label text width) Canonical `indicator` slot is decorative and author-accessible. Material's ink-bar is fully internal — consumers cannot replace it or compose it independently. The only control surface is `fitInkBarToContent` on the group. Position is computed by `_alignInkBarToSelectedTab()` which calls `_inkBar.alignToElement()` on the selected label wrapper. Source: github.com/angular/components blob src/material/tabs/paginated-tab-header.ts (fetched 2026-05-04).
anatomy[tabpanel] extended + `preserveContent: boolean` @Input() on MatTabGroup — when true, all tab panels remain in the DOM (hidden via CSS display:none) rather than being destroyed on deselection; enables instant re-activation without re-mount cost The canonical anatomy does not model an always-in-DOM mode for panels. Material ships `preserveContent` as an explicit performance trade-off knob — lazy-unmount (default) vs keep-all-alive. This maps to the canonical `lazyPanelRender` performance threshold but is controlled at the component level rather than per-panel. Source: github.com/angular/components blob src/material/tabs/tab-group.ts (fetched 2026-05-04).
axes.variants omitted Canonical variants are `line | contained | pill`. MatTabGroup ships only the Material Design line-tab visual style. There is no `variant` input — consumers wanting contained or pill appearances must layer CSS overrides or use a higher-level design-system component. The M3 Material spec defines Primary Tabs and Secondary Tabs as the two variants; these differ visually from `contained` and `pill` in the canonical. Source: github.com/angular/components blob src/material/tabs/tab-group.ts (fetched 2026-05-04).
axes.properties[orientation] reshaped `headerPosition: 'above' | 'below'` @Input() on MatTabGroup — controls whether the tab header renders above or below the panel. True horizontal vs vertical tab orientation (side-by-side tablist + panel) is not natively supported. Canonical `orientation: horizontal | vertical` switches the tablist axis and arrow-key direction. Material's `headerPosition` only controls the vertical position of the header band (above or below the content area); it does not produce a left-rail vertical tabs layout. Vertical orientation as a first-class prop is absent — consumers who need it must compose via MatTabNav + custom layout. Source: github.com/angular/components blob src/material/tabs/tab-group.ts (fetched 2026-05-04).
axes.properties[activation] omitted Canonical `activation: automatic | manual` controls whether arrow-key focus immediately selects a tab or requires Enter/Space to commit. MatTabGroup has no such input. The internal FocusKeyManager + internal `selectFocusedIndex` EventEmitter effectively implement only the manual path (arrow keys move focus; Enter/Space trigger `_keyManager`-driven selection via `selectFocusedIndex`). There is no API surface to switch to automatic activation. Source: github.com/angular/components blob src/material/tabs/paginated-tab-header.ts (fetched 2026-05-04).
axes.properties[density] omitted Canonical `density: comfortable | compact` property. MatTabGroup has no density input. Density in Angular Material is handled at the global theme level via the Material Design density system (SCSS `mat.tab-group-density($scale)` mixin), not as a per-instance prop. Per-instance density control is absent from the component API. Source: github.com/angular/components blob src/material/tabs/tab-group.ts (fetched 2026-05-04).
events[selectedChange] renamed `selectedTabChange` @Output() EventEmitter<MatTabChangeEvent> (carries { index: number, tab: MatTab }) and `selectedIndexChange` @Output() EventEmitter<number> (index only, for [(selectedIndex)] two-way binding) Canonical `selectedChange` emits the tab id string. Material splits the concept into two outputs: `selectedTabChange` carries the full MatTabChangeEvent (index + tab reference), and `selectedIndexChange` carries only the numeric index for two-way binding ergonomics. Payload is index-based (number), not id-based (string) — consumers who model tabs by string id must map through their own tab definitions. Source: github.com/angular/components blob src/material/tabs/tab-group.ts (fetched 2026-05-04).
events[tabActivate] reshaped `focusChange` @Output() EventEmitter<MatTabChangeEvent> — fires when keyboard focus moves to a different tab (arrow-key navigation), not only on Enter/Space activation Canonical `tabActivate` is defined only for `activation: manual` and fires exclusively when the user presses Enter or Space to commit a focused tab. Material's `focusChange` fires on every focus movement including arrow-key traversal, and it fires regardless of whether selection changes. It cannot be used as a direct substitute for canonical `tabActivate` because (a) Material has no manual-activation mode and (b) `focusChange` includes non-activating focus events. Source: github.com/angular/components blob src/material/tabs/tab-group.ts (fetched 2026-05-04).
Why this audit reads the way it does

Angular Material Tabs is a high-level, batteries-included compound component rather than the primitive-per-slot decomposition the canonical anatomy describes. The most significant divergences: 1. No variant system — Material ships only the line-tab visual style; contained and pill are out of scope for the library. 2. No orientation: vertical — headerPosition above/below is not a true side-rail layout. Vertical tabs require MatTabNav. 3. No activation-mode control — the keyboard model is fixed at manual (arrow focus + Enter/Space select). Automatic activation is not configurable. 4. Monolithic compound component — the tablist, tab triggers, ink-bar, and pagination controls are all managed internally; only the declarative <mat-tab> children are consumer-authored. Notable Material strengths relative to the canonical: 1. Built-in overflow pagination — arrow buttons appear automatically when tabs overflow, without consumer wiring. 2. preserveContent input — explicit keep-all-alive knob covering the canonical lazy-panel performance threshold with one boolean. 3. animationDuration input — granular body/header timing control without wiring Angular animations manually. 4. fitInkBarToContent — canonical indicator is described as presentational and geometry-driven; Material ships exactly that with a size-to-label option the canonical does not model.

headlessui Tab.Group / Tab.List / Tab / Tab.Panels / Tab.Panel
import {
TabGroup, TabList, Tab, TabPanels, TabPanel,
} from '@headlessui/react';
function ProfileTabs() {
return (
<TabGroup defaultIndex={0} onChange={(index) => console.log('tab changed', index)}>
<TabList className="flex gap-1 border-b">
<Tab className="px-4 py-2 data-[selected]:border-b-2 data-[selected]:border-blue-600">
Account
</Tab>
<Tab className="px-4 py-2 data-[selected]:border-b-2 data-[selected]:border-blue-600">
Notifications
</Tab>
<Tab
className="px-4 py-2 data-[selected]:border-b-2 data-[selected]:border-blue-600"
disabled
>
Billing
</Tab>
</TabList>
<TabPanels className="mt-4">
<TabPanel>Account settings content</TabPanel>
<TabPanel>Notification preferences</TabPanel>
<TabPanel>Billing (unavailable)</TabPanel>
</TabPanels>
</TabGroup>
);
}

Divergence

From Type → To Rationale
anatomy[indicator] omitted Headless UI ships no indicator primitive. The canonical decorative underline/pill that tracks the selected tab is entirely consumer- implemented — a positioned element whose offset must be computed from the selected Tab's bounding box and animated manually. Headless UI exposes `data-selected` on each `Tab` so consumers can derive indicator position via refs, but no library component participates in the motion.
anatomy[tabpanel] reshaped TabPanels (wrapper div) + TabPanel (individual panel) Canonical anatomy has a single `tabpanel` slot that maps to one rendered element per tab. Headless UI inserts an additional `TabPanels` wrapper component (renders a `div` by default) that encapsulates all panels. This extra layer is not part of the ARIA tabpanel pattern and has no semantic role; it exists as a pairing mechanism so `TabPanel` components can discover their index via React context. Consumers using `as={Fragment}` on `TabPanels` can suppress the wrapper element entirely.
axes.variants[line] omitted Headless UI is intentionally unstyled; it ships no variant tokens or CSS for line, contained, or pill tab appearances. All visual styling is consumer-applied via className or a CSS-in-JS solution. The `data-selected` and `data-hover` / `data-focus` / `data-active` attributes on `Tab` are the styling hooks.
axes.properties[density] omitted No density prop. Padding and spacing between tabs are consumer CSS. Headless UI stops at the accessibility primitive and provides no design-system density scale.
axes.properties[orientation] reshaped vertical prop (boolean) on TabGroup Canonical defines `orientation` as an enum (`horizontal` | `vertical`). Headless UI collapses this to a single boolean `vertical` (default `false`) on `TabGroup`. The prop drives `aria-orientation="vertical"` on the tablist and switches arrow- key navigation from Left/Right to Up/Down. The two-value enum and the boolean are informationally equivalent, but the boolean API does not extend to future orientations (e.g. a hypothetical `radial` layout). Source: https://headlessui.com/react/tabs (TabGroup props table, "vertical").
axes.properties[activation] reshaped manual prop (boolean) on TabGroup Canonical defines `activation` as an enum (`automatic` | `manual`). Headless UI collapses this to a boolean `manual` (default `false`, i.e. automatic). When `manual={true}`, arrow keys move focus but do not change the selected tab; Enter or Space commits selection. Source: https://headlessui.com/react/tabs (TabGroup props, "manual").
events[selectedChange] reshaped onChange(index: number) on TabGroup Canonical `selectedChange` carries the id of the newly selected tab as a string. Headless UI's `onChange` delivers a zero-based integer index instead. Consumers that need string identity must maintain an external index-to-id mapping. In controlled usage `selectedIndex` (Number) is paired with `onChange`; there is no string-keyed API. Source: https://headlessui.com/react/tabs (TabGroup — onChange).
events[tabActivate] omitted Canonical `tabActivate` is a discrete event emitted only in manual activation mode when the user presses Enter/Space on a focused but not-yet-selected tab. Headless UI collapses navigation and activation into a single `onChange(index)` callback — in automatic mode it fires on every focus shift; in manual mode it fires on Enter/Space commit. There is no separate "focus moved but not yet activated" event; consumers needing to distinguish hover-focus from activation must instrument individual `Tab` render props (`focus`, `selected`) themselves.
axes.states.data[busy] omitted Headless UI has no built-in `aria-busy` management for lazily- loaded panels. The `unmount` prop on `TabPanel` controls whether inactive panels are removed from the DOM (default `true`) or kept hidden (`static={true}` skips unmount logic entirely). Neither mechanism signals `aria-busy`; consumers must set it manually when panel content is in-flight.
axes.states.data[lazy] omitted There is no `lazy` registration API on `TabPanel`. The closest affordance is the `unmount` prop (default `true`), which defers rendering of inactive panels until first selection. However, `unmount` does not coordinate async data-fetching or emit lifecycle hooks; it is purely a DOM mount/unmount toggle. Consumers implement deferred data loading independently of the Headless UI component.
Why this audit reads the way it does

Headless UI React's Tab primitives are unstyled, accessibility- focused, and intentionally minimal — consistent with the same library's Dialog audit. The divergences from the canonical Tabs cluster into three groups: 1. No visual layer. Canonical variants (line / contained / pill) and density are entirely consumer-applied. Headless UI provides data-selected / data-hover / data-focus / data-active attributes on Tab as styling hooks; no CSS ships with the library. 2. Boolean API compression. Both `orientation` (enum) and `activation` (enum) are collapsed to single boolean props (`vertical`, `manual`) on TabGroup. This is simpler to use but loses forward-extensibility. 3. Index-based selection. `onChange` delivers a numeric index, not a string id. This is the most consequential divergence for consumers building controlled, url-driven, or keyboard-accessible tab sets that refer to tabs by stable identity. The extra `TabPanels` wrapper (not present in canonical) is a context- propagation artefact; it can be suppressed with `as={Fragment}`. No async-panel lifecycle (busy / lazy / error states) is shipped; these are consumer responsibilities, unlike the canonical contract which specifies `aria-busy` coordination.

radix Tabs
import * as Tabs from '@radix-ui/react-tabs';
<Tabs.Root defaultValue="account" orientation="horizontal" activationMode="automatic">
<Tabs.List aria-label="Manage your account">
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="notifications">Notifications</Tabs.Trigger>
<Tabs.Trigger value="billing" disabled>Billing</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">
<p>Account settings panel content.</p>
</Tabs.Content>
<Tabs.Content value="notifications">
<p>Notification preferences panel content.</p>
</Tabs.Content>
<Tabs.Content value="billing" forceMount>
<p>Billing details panel content.</p>
</Tabs.Content>
</Tabs.Root>

Divergence

From Type → To Rationale
anatomy[tablist] renamed Tabs.List Same role and semantic (`role="tablist"`). Radix uses the compound- component name "List" rather than the ARIA term "tablist". The `aria-orientation` attribute is set automatically from the root's `orientation` prop, matching the canonical contract exactly.
anatomy[tab] renamed Tabs.Trigger Same interactive role (`role="tab"`). Radix names the sub-component "Trigger" to align with its own primitives vocabulary (Toggle, Select, Popover all use "Trigger"). The canonical term "tab" follows ARIA; Radix diverges for internal naming consistency. Source: https://www.radix-ui.com/primitives/docs/components/tabs#trigger
anatomy[indicator] omitted Radix ships no indicator sub-component. The active trigger carries `data-state="active"` and consumers apply CSS (e.g. a pseudo-element underline) targeting that attribute. The canonical indicator slot is a presentational element derived from selected-tab geometry; Radix intentionally leaves its implementation to the consumer CSS layer, not the primitive. Source: https://www.radix-ui.com/primitives/docs/components/tabs#trigger
anatomy[tabpanel] renamed Tabs.Content Same structural role (`role="tabpanel"`). Radix uses "Content" for consistency with its Dialog.Content / Popover.Content naming pattern. Radix auto-wires `aria-labelledby` to its corresponding Trigger, matching the canonical bidirectional ARIA relationship contract. Source: https://www.radix-ui.com/primitives/docs/components/tabs#content
axes.properties[activation] renamed activationMode — prop on Tabs.Root; values: automatic | manual Canonical names the axis `activation` with values `automatic` and `manual`. Radix names it `activationMode` and uses the same two values. The behavioural contract (automatic: arrow-key focus activates; manual: Enter/Space required) is identical. Source: https://www.radix-ui.com/primitives/docs/components/tabs#root
axes.properties[density] omitted Radix is an unstyled primitive and ships no density prop. Spacing, padding, and typographic scale for tabs are entirely consumer-controlled via CSS classNames on Tabs.List and Tabs.Trigger. The canonical `comfortable | compact` axis is a design-system convention layered above the primitive.
axes.variants[line] omitted Radix ships no visual variant system (line / contained / pill). Radix.Tabs is unstyled; variant appearance is achieved through consumer CSS applied to the `data-state` and `data-orientation` attributes. All three canonical variants are implementable but are not bundled. Source: https://www.radix-ui.com/primitives/docs/components/tabs
events[selectedChange] renamed onValueChange (prop on Tabs.Root; signature: value: string => void) One-to-one match in semantics. Canonical names it `selectedChange` for framework-neutral consistency; Radix uses `onValueChange` following React convention for controlled-value callbacks. The payload (string id of the newly selected tab) and firing edges (automatic: on focus; manual: on Enter/Space) match the canonical contract. Source: https://www.radix-ui.com/primitives/docs/components/tabs#root
events[tabActivate] omitted Radix does not expose a separate activate-only event distinct from `onValueChange`. In manual mode, `onValueChange` fires only on Enter/Space (the activation edge), which collapses the canonical distinction between "focus-move" (tabActivate omitted) and "selection-commit" (onValueChange). Consumers that need to distinguish navigation from activation in manual mode must track focus themselves; Radix provides no separate callback for the `tabActivate` edge. Source: https://www.radix-ui.com/primitives/docs/components/tabs#root
axes.states.data[busy] omitted Radix has no built-in busy/loading state for async panel content. The `forceMount` prop on Tabs.Content keeps the panel in the DOM regardless of selection, but no `aria-busy` lifecycle is managed by the library. Consumers must set `aria-busy="true"` on the Content element themselves during in-flight fetches. The canonical `busy` state and its associated `aria-busy` contract are unimplemented at the Radix layer. Source: https://www.radix-ui.com/primitives/docs/components/tabs#content
contracts.nonNegotiable[disabled-tabs-use-aria-disabled] reshaped Tabs.Trigger disabled prop sets native HTML disabled on the underlying button element, plus a data-disabled styling hook The canonical non-negotiable contract requires `aria-disabled="true"` so disabled tabs remain in the focus order and are keyboard-reachable. Radix uses the native HTML `disabled` attribute, which removes the button from the focus order entirely — arrow-key navigation skips disabled triggers. This contradicts APG "Managing disabled tabs" guidance. The `data-disabled` attribute Radix adds is a styling hook only; it does not restore keyboard reachability. Consumers who need the canonical behaviour must avoid the `disabled` prop and implement `aria-disabled` + no-op activation manually. Source: https://raw.githubusercontent.com/radix-ui/primitives/main/packages/react/tabs/src/tabs.tsx (verified: HTML `disabled={disabled}` on button element, no aria-disabled)
Why this audit reads the way it does

Radix Tabs is a low-level unstyled primitive that correctly implements the ARIA tablist/tab/tabpanel triad, roving-tabindex keyboard model, and the automatic/manual activation modes. Most anatomy divergences are renames following Radix's own primitives vocabulary (List, Trigger, Content). The significant gaps relative to the canonical anatomy are: (1) no indicator sub-component — consumers must CSS their own; (2) no visual variant system — the canonical line/contained/pill axis is a design-system layer above Radix; (3) no async panel lifecycle (busy/lazy/error states) — Radix provides `forceMount` and `data-state` but no `aria-busy` management; and (4) the disabled-tab contract is broken — Radix uses HTML `disabled` which removes triggers from the focus order, contradicting the APG requirement that disabled tabs remain keyboard-reachable via `aria-disabled`.

Both

Figma↔Code mismatches

Where designer and developer worlds typically misalign on this component.

  1. 01
    Figma

    Each tab state (default / hover / selected / disabled) modeled as a separate Figma variant on the tab component

    Code

    A single tab element with `:hover`, `:focus-visible`, and `aria-selected` driving CSS

    Consequence

    Variant 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.

    Correct

    States 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.

  2. 02
    Figma

    The active-tab underline drawn as a separate decorative line component on the artboard

    Code

    An indicator pseudo-element (or absolute-positioned div) whose position is bound to the selected tab

    Consequence

    Designers 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.

    Correct

    Document 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.

  3. 03
    Figma

    Vertical tabs drawn as a left sidebar with no semantic relationship to the right-hand content

    Code

    Vertical tabs use the same `role="tablist"` with `aria-orientation="vertical"` and arrow keys reverse from horizontal to vertical

    Consequence

    Designers 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.

    Correct

    Treat 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).

  4. 04
    Figma

    Manual vs. automatic activation modeled as two visually identical Figma components

    Code

    A property toggles whether arrow-key navigation activates the tab on focus (automatic) or only on Enter / Space (manual)

    Consequence

    Designers 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.

    Correct

    Document `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.

  5. 05
    Figma

    A tablist that overflows the available width drawn either as wrapped onto a second row or simply truncated at the edge

    Code

    Overflowing tablists scroll horizontally with edge-fade affordances and Home / End / arrow keys that scroll the focused tab into view

    Consequence

    Designers 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.

    Correct

    Standardise 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).

Both

Variants, properties, states

Variants

Structurally different versions of the component.

line contained pill

Properties

The same component, parameterised.

PropertyType
orientation horizontal | vertical
activation automatic | manual
density comfortable | compact

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
selectedbusylazyerror
Both

Figma ↔ Code property map

FigmaKindCodeNotes
VariantEnumvariantMaps line / contained / pill.
OrientationEnumorientationhorizontal / vertical. Vertical is forced to horizontal at and below `breakpoint.sm` per the responsive block.
DensityEnumdensity
ActivationEnumactivationautomatic / manual. In Figma a Variant property since the visual treatment of focus differs slightly between modes; in code it drives whether arrow-key navigation activates on focus.
Tab CountNumbertabs.lengthFigma exposes a Variant for 2 / 3 / 4 / 5 / 6+ tab counts to allow preview-time layout review. Code accepts an array of tab definitions; the count is `.length`, not a separate prop.
SelectedEnumselectedIdFigma exposes the selected tab as a Variant for preview; in code this is controlled state on the parent, not a Figma-style prop.
Both

State transitions

FromToTrigger
lazybusyUser activates a tab whose panel was registered as `lazy` (deferred load). The panel begins fetching; the tab stays selected, the panel announces `aria-busy="true"`. Subsequent activations of the same tab go selected → selected (already loaded) and skip this edge.
selectedbusyPanel content reloads in place (e.g. consumer-triggered refresh, route-driven re-fetch on an already-rendered panel). The tab stays selected; `aria-busy="true"` on the panel signals the in-flight state to assistive tech.
busyselectedPanel content has loaded successfully; `aria-busy` clears and the panel renders the resolved content.
busyerrorPanel content load failed (network error, validation failure, thrown render error). Tab stays selected; `tab` and `tabpanel` carry `data-state="error"` so the consumer can render an error UI in place without forcing a selection-change.
errorbusyConsumer-driven retry from the error UI re-issues the fetch.
errorselectedUser selects a different tab and returns; the error is treated as transient and the panel re-fetches as if `lazy`. Implementations that prefer sticky errors stay in `error` until an explicit retry — both are canonical, the choice is documented per implementation.
Designer

Figma anatomy

Slot Figma type Hint
tablist frame Auto-layout horizontal frame; gap and padding from token set
tab instance Tab item component instance; selected state via component property
icon-leading from icon-leading-text instance Icon component instance; size bound to host's size token
label from icon-leading-text text Text style bound to a "label" component property; truncates with ellipsis when single-line variant overflows
icon-trailing from icon-leading-text instance Icon component instance; visibility bound to host's "has trailing icon" property
indicator rectangle 2-3px line component variant; position bound to selected tab
tabpanel frame Auto-layout vertical frame; visibility driven by selected variant
Dev

Code anatomy

Slot Code slot Semantic
tablist tablist tablist
tab tab tab
icon-leading from icon-leading-text icon-leading presentational-or-img
label from icon-leading-text label text
icon-trailing from icon-leading-text icon-trailing presentational-or-img
indicator indicator presentational
tabpanel tabpanel tabpanel
Dev

Cross-framework expression

FrameworkStructure mechanismVariant 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
Both

Events

  1. selectedChange
    Payload
    The id of the newly selected tab — always a string matching one of the rendered tab ids; never empty. In `activation: automatic` mode fires on every focus change inside the tablist; in `activation: manual` mode fires only when the user activates a focused tab via Enter or Space.
    Web Components
    `change` CustomEvent on the `<ui-tabs>` host with `event.detail = { selectedId }`. Listen on the host, not on individual tab elements.
    React
    `onValueChange(value: string)` (Radix `Tabs.Root`) or `onSelectionChange(key: Key)` (React Aria `useTabList`). Both fire on the same edge.
    Angular Signals
    `output<string>('selectedChange')`; pair with `[(selected)]` for two-way binding when the parent owns selection.
    Vue
    `@update:modelValue` for `v-model` on the selected id. Headless UI `<TabGroup>` provides `@change(index: number)` for consumers that prefer index-based selection.
  2. tabActivateoptional
    Payload
    The id of the tab whose panel was requested. Canonical only in `activation: manual` mode — the user has moved focus across tabs and pressed Enter or Space to commit. Implementations running in `activation: automatic` mode do not emit this event; consumers should listen on `selectedChange` and ignore `tabActivate` for those instances. The two events are deliberately separated so manual-activation consumers can distinguish navigation (focus-only) from activation (panel commit) without inferring it from selection-change semantics.
    Web Components
    Same `change` mechanism, but only emitted on activation. The highlight state is internal and not exposed.
    React
    Radix `Tabs` does not expose an activate-only event; React Aria `useTabList` exposes `onAction(key)` for the activate-only path so consumers can distinguish navigation from activation.
    Angular Signals
    `output<string>('tabActivate')`; only emits in manual activation mode.
    Vue
    `@activate` event in manual mode; `automatic` mode collapses it into `@update:modelValue`.
Both

Performance thresholds

  • tablistOverflowtab-count7tabs

    Above ~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-size100kb

    Tabpanels 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.

Both

Internationalisation

RTL · mirroring

Horizontal tablist renders right-to-left — the first tab in source order appears on the inline-start (visual right in RTL). Arrow-key navigation reverses per APG: ArrowRight moves to the *previous* tab in RTL, ArrowLeft to the *next* — the keys follow visual direction, not source order. Vertical tablists are direction-neutral (Up/Down keys unchanged). Selected-tab indicator slides along the inline axis using logical properties (`inset-inline-start`); slide direction reverses naturally. Overflow scrolling reverses direction too — Home scrolls to the inline-start (visual right in RTL).

Text expansion

Tab labels can grow 30–50% in DE/RU/FI; tablist may overflow the viewport earlier in those languages. Horizontal-scroll handling (per the existing mistake `tabs-overflow-no-scroll-into-view`) accommodates expansion without wrapping. Vertical orientation naturally accommodates expansion via fixed inline-size on the tablist column. Density `compact` is risky for long-text languages — consider `comfortable` as the default in heavy-expansion locales.

Both

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.
icon-leading `aria-hidden="true"` on the SVG when the icon is purely decorative (paired with a visible label). If the icon is the sole signal of the host's intent (icon-only host variant), the host carries an `aria-label` describing the action — the icon never announces itself.
label Plain text node; no special role. The label is the host's accessible name unless overridden by `aria-label` / `aria-labelledby`. Avoid visually-hidden modifiers — the visible text and the announced name should match.
icon-trailing `aria-hidden="true"` on the SVG. If the icon communicates state ("opens menu", "external link", "syncing"), pair it with a visually-hidden text node inside the host or absorb the meaning into the host's `aria-label` — the icon must never carry its own accessible name.
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.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
TabFocus 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 / EndFocus 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

TriggerExpected
Focus enters a tabSR 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-children
  • aria-required-parent
  • aria-roles
  • tabindex
  • color-contrast
  • focus-order-semantics

Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe: /api/components/tabs/a11y-fixture.json

Both

Contracts

Non-negotiable contracts

  1. APGAPG: Tabs pattern — Keyboard interaction

    Tablist uses the roving-tabindex pattern: only the selected tab carries `tabindex="0"`, all others carry `tabindex="-1"`. Arrow keys (Left/Right horizontal, Up/Down vertical) move focus and update the roving index; Tab enters and exits the tablist as a single stop.

    Without roving tabindex, Tab walks through every tab plus the panels below; SR users hear "tab 1 of N" announcements that conflict with document order. The canonical "tablist is one stop, arrows navigate inside" contract is broken from the keyboard perspective.

  2. APGAPG: Tabs pattern — aria-selected

    `aria-selected` on each tab is the single source of truth for selection state. CSS modifiers and the indicator animation derive from `[aria-selected="true"]` — never from a parallel `data-selected` attribute that has to stay in sync.

    When selection lives only in `data-selected`, AT reports every tab as unselected; the visual selection is a sighted-only signal. The contract that "the attribute is the meaning, the visual derives from it" applies here as it does for `aria-expanded` on Disclosure / Accordion.

  3. APGAPG: Tabs pattern — aria-controls and aria-labelledby

    Each tab carries `aria-controls` referencing its tabpanel's id; each tabpanel carries `aria-labelledby` referencing its tab's id. The bidirectional relationship is wired even when tabs and panels are visually adjacent.

    Without `aria-labelledby` on the panel, SR users navigating the page out-of-order land on a panel with no signal of which tab owns it. Without `aria-controls` on the tab, there is no programmatic path from tab to panel for AT and assistive navigation features.

  4. APGAPG: Tabs pattern — Managing disabled tabs

    Disabled tabs use `aria-disabled="true"` — never the HTML `disabled` attribute. The disabled tab remains focusable and announceable; activation (Enter / Space) is the operation that becomes a no-op.

    HTML `disabled` removes the tab from the focus order; arrow-key navigation jumps over it without warning, leaving keyboard users stranded mid-traversal and SR users with no announcement that the tab exists.

Both

Common mistakes

Blocker

#tabs-no-arrow-keys

Tab key cycles through every tab instead of arrow keys

Problem

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.

Fix

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.

Blocker

#tabs-panel-not-labelled

Tabpanels missing `aria-labelledby` to their tab

Problem

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.

Fix

Each tabpanel sets `aria-labelledby` to the id of its tab. Each tab sets `aria-controls` to the id of its panel.

Blocker

#tabs-state-as-data-attribute-only

Selection driven only by a `data-selected` attribute, no `aria-selected`

Problem

Visual selection works (CSS targets the data attribute) but assistive tech reports every tab as unselected.

Fix

`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.

Major

#tabs-disabled-but-not-skipped

Disabled tabs cannot receive keyboard focus

Problem

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.

Fix

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.

Major

#tabs-lazy-panel-no-aria-busy

Lazy-loaded tabpanel renders empty without an `aria-busy` announcement

Problem

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.

Fix

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.

Major

#tabs-indicator-not-rtl-aware

Indicator animation hard-codes left-to-right movement

Problem

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.

Fix

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.

Minor

#tabs-overflow-no-scroll-into-view

Overflowing tablist does not scroll the focused tab into view

Problem

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.

Fix

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.