Tabs
Usage
Use tabs to switch between related views of the same object without leaving the page: a van's overview, service history and documents. Don't use tabs for steps in a sequence (stepper) or to compare content side by side.
- 2–7 tabs; labels are short nouns ("Service history"), sentence case, no verbs.
- Underline for page sections; contained for switching how the same data is shown (Map / List / Timeline).
- If each tab has its own URL, it's navigation: use links with
aria-current="page"inside a<nav>(same classes). - Counts are context, not alerts; keep them in the label's text flow.
Anatomy
- Tab list —
.tabs__list[role=tablist][aria-label] - Tab —
button.tabs__tab[role=tab]witharia-selectedandaria-controls - Count (optional) —
.tabs__count - Selected indicator — 2px bar + semibold + color
- Panel —
.tabs__panel[role=tabpanel][aria-labelledby][tabindex=0]
Examples
Without JavaScript all panels are visible one after another; js/tabs.js hides the unselected ones. States marked hover, focus-visible and active are frozen with .is-hover, .is-focus-visible, .is-active. Those classes share their rule with the real pseudo-class, so what you see is exactly what users get. Never use them in product code.
Tabs
Van KX-219 · Ford Transit · 84,210 km · North Yard depot.
12 completed work orders since March 2024.
Next brake inspection due 14 November.
Registration, insurance and the last emissions test.
Automatic activation: arrow keys select as they move (panels are already loaded).
Overview for van KX-219.
Hover state.
Focus-visible state.
3 tasks: replace front pads, check rotors, road test.
Front brake pad set ×1, rotor ×2.
Available after the work order is closed.
Disabled tabs stay focusable so people find them; explain why near the tabs: "Invoice is available after closing."
Map of 42 vans in service.
List of 42 vans in service.
Today's jobs on a timeline.
Manual activation (Enter/Space) — the map is expensive to render.
North Yard depot: vans, mechanics and open work orders.
Harbor Road depot: vans, mechanics and open work orders.
Eastgate depot: vans, mechanics and open work orders.
Airport depot: vans, mechanics and open work orders.
Riverside depot: vans, mechanics and open work orders.
Old Town depot: vans, mechanics and open work orders.
Southport depot: vans, mechanics and open work orders.
Hill Street depot: vans, mechanics and open work orders.
Too many tabs for the width: the list scrolls with snap points. Arrow keys scroll the focused tab into view.
<div class="tabs tabs--underline"> <!-- add data-activation="manual" for expensive panels -->
<div class="tabs__list" role="tablist" aria-label="Van KX-219">
<button type="button" class="tabs__tab" role="tab" id="van-overview" aria-selected="true" aria-controls="van-overview-panel">Overview</button>
<button type="button" class="tabs__tab" role="tab" id="van-history" aria-selected="false" aria-controls="van-history-panel">
Service history <span class="tabs__count">12</span>
</button>
<button type="button" class="tabs__tab" role="tab" id="van-invoice" aria-selected="false" aria-controls="van-invoice-panel" aria-disabled="true">Invoice</button>
</div>
<div class="tabs__panel" role="tabpanel" id="van-overview-panel" aria-labelledby="van-overview" tabindex="0">…</div>
<div class="tabs__panel" role="tabpanel" id="van-history-panel" aria-labelledby="van-history" tabindex="0">…</div>
<div class="tabs__panel" role="tabpanel" id="van-invoice-panel" aria-labelledby="van-invoice" tabindex="0">…</div>
</div>
<script type="module">import { init } from "./js/tabs.js"; init();</script>
<!-- Tabs that change the URL are navigation: links + aria-current, no tab roles -->
<nav class="tabs tabs--underline" aria-label="Van KX-219 sections">
<ul class="tabs__list">
<li><a class="tabs__tab" href="/vehicles/KX-219" aria-current="page">Overview</a></li>
<li><a class="tabs__tab" href="/vehicles/KX-219/history">Service history</a></li>
</ul>
</nav>
API
| Hook | Values | Purpose |
|---|---|---|
.tabs | block class | Wrapper |
.tabs--underline | --contained | modifier | Visual variant |
.tabs--overflow | modifier | Room for the scrollbar when tabs overflow |
data-activation="manual" | attribute | Select on Enter/Space instead of on focus |
.tabs__list | __tab | __count | __icon | __panel | parts | See anatomy |
aria-selected / aria-current="page" | attribute | Selected tab / current URL tab |
aria-disabled="true" | attribute | Unavailable, focusable |
tabs-change | event | Fired on the tablist: detail { tab, panel } |
Do and don't
Overview for van KX-219.
Service history for van KX-219.
Documents for van KX-219.
1. Check brakes for van KX-219.
2. Check tires for van KX-219.
3. Sign off for van KX-219.
Accessibility
| Key | Behavior |
|---|---|
| Tab | Moves into the tab list (to the selected tab), then to the panel |
| ← / → | Previous / next tab, wrapping (mirrored in RTL); selects it with automatic activation |
| Home / End | First / last tab |
| Enter / Space | Selects the focused tab (manual activation) |
- Pattern: WAI-ARIA APG Tabs with roving tabindex (
js/lib/roving-focus.js) — one Tab stop for the whole list. - Panels have
tabindex="0"so keyboard users reach text-only panels. - Selected tab: bar, weight and color — never color alone; forced colors use
Highlight. - Screen readers announce "Service history, tab, 2 of 4, selected".
Tokens
| Custom property | Purpose |
|---|---|
--color-text-muted | Unselected label |
--color-selected-fg | -border | Selected label and bar |
--color-action-ghost-bg-hover | Hover |
--color-border-default | List baseline |
--color-bg-subtle | -surface, --color-border-strong | Contained variant |
--size-control-md | -sm | Tab heights |
--color-text-disabled | Disabled tab |