Tabs
Navigation
Open in Storybook: Tabs (opens in a new tab)
Overview
Tabs switch between peer views of the same thing, in place, without changing the URL or the page. HTML has no tab element, so ARIA is the semantics: the roles replace the list, and a screen reader hears tabs, not a list.
The rule. A named ul role="tablist" of li role="tab", and a div role="tabpanel" for each, wired by ids. tabindex follows selection, the li gets click, Enter and Space by hand, and focus never moves into the panel by script.
Anatomy
- Tablist
ul role="tablist"Holds the tabs and names the set.
- Tab
li role="tab"One view of the set. The selected one is in the Tab order.
- Tab panel
div role="tabpanel"The view of the selected tab, named by it.
Usage
Use cases
Five common tab sets, each the real component and each working.
Product details
Horizontal · automatic
Description
Ristretto with a metal cup support. Intense, short, with notes of cocoa.
Specs
It stays in the DOM, hidden, until its tab is selected.
Reviews
4.6 out of 5, from 312 reviews. Most mention the cocoa notes.
aria-label="Product details"Arrows show the panel at once: the panels are already in the page.
Account settings
Vertical · automatic
- Profile
- Security
- Alerts
Profile
Name, photo, language.
Security
Password, two-step sign-in.
Alerts
Email and push notifications.
aria-orientation="vertical"ArrowUp / ArrowDown replace Left / Right, at every width: the column stays beside its panel on a phone too.
Code sample per framework
Horizontal · manual
HTML
<ul role="tablist">…</ul>
React
<Tabs items={items} label="Product" />
Vue
<Tabs :items="items" label="Product" />
Enter / Space selects
Manual when showing a panel is slow (fetch, heavy render). tabindex follows selection, not the arrows.
Right-to-left
dir="rtl"الوصف
لوحة الوصف
المواصفات
لوحة المواصفات
التقييمات
لوحة التقييمات
Logical properties
ArrowLeft goes to the next tab, ArrowRight to the previous.
Many tabs
Overflow · scrollable tablist
January
Orders in January.
February
Orders in February.
March
Orders in March.
April
Orders in April.
May
Orders in May.
June
Orders in June.
July
Orders in July.
August
Orders in August.
September
Orders in September.
October
Orders in October.
November
Orders in November.
December
Orders in December.
A wrapper scrolls ·
ulismax-contentScroll a wrapper around the ul, not the ul. An arrow at the end that has more tabs.
Props
| Prop | Does | Default |
|---|---|---|
items | The tabs: id, label, content | Required |
label | Names the tablist | Required |
activation | automatic or manual | automatic |
orientation | horizontal or vertical | horizontal |
scrollable | A strip that does not fit scrolls | false |
selectedId · onSelectedIdChange | The page keeps the selection | — |
before · after | What goes beside the strip | — |
Example
import { Tabs } from '@/content/examples/tabs/Tabs'
<Tabs
label="Product details"
scrollable
items={[
{ id: 'description', label: 'Description', content: <p>…</p> },
{ id: 'specs', label: 'Specs', content: <p>…</p> },
{ id: 'reviews', label: 'Reviews', content: <p>…</p> },
]}
/>Specs
States
The look is read from aria-selected, never a class, so it cannot drift from what is announced.
| State | Look |
|---|---|
| Selected | Bold label and a 3px bar |
| Unselected | Regular label, no bar. Text still 4.5:1 |
| Hover | Darker label and a light bar, only under @media (hover: hover) |
| Focus | A 3px ring on :focus-visible, on a selected tab or on one an arrow reached (manual) |
| Forced colours | The bar is Highlight and the label CanvasText: bold still shows the state |
| Reduced motion | No transition on the colour and the bar |
Layout
Tabs never wrap onto a second row: it breaks the order of the arrows that people see.
| Tablist | When | Layout |
|---|---|---|
| One row | The labels fit | Every tab visible, the bar under the selected one |
| Scrollable row | The labels do not fit | A wrapper scrolls, with an arrow at each end that has more tabs |
| Vertical | Many tabs or long labels, at any width | One column beside the panel, which narrows and wraps. Never a row |
Markup
<ul role="tablist" aria-label="Product details">
<li role="tab" id="t1" aria-selected="true"
aria-controls="p1" tabindex="0">Description</li>
<li role="tab" id="t2" aria-selected="false"
aria-controls="p2" tabindex="-1">Specifications</li>
<li role="tab" id="t3" aria-selected="false"
aria-controls="p3" tabindex="-1">Reviews</li>
</ul>
<div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
<div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>…</div>
<div role="tabpanel" id="p3" aria-labelledby="t3" tabindex="0" hidden>…</div>Overflow
A wrapper scrolls, never the ul: a global ul { overflow: visible !important } would beat it.
<div class="tabs-viewport"> <!-- the arrows sit against this -->
<div class="tabs-scroll"> <!-- the wrapper scrolls -->
<ul role="tablist" aria-label="Product details">…</ul>
</div>
<button class="tabs-arrow" data-edge="end" type="button"
tabindex="-1" aria-hidden="true">›</button> <!-- only if more tabs -->
</div>.tabs-viewport { position: relative; }
.tabs-scroll {
overflow-x: auto; scrollbar-width: thin;
scroll-padding-inline: 2.5rem; /* a focused tab stays clear of the arrows */
}
ul[role="tablist"] {
display: flex;
width: max-content; /* the strip keeps its length */
min-width: 100%; /* and fills when it is short */
list-style: none; margin: 0; padding: 0;
border-block-end: 1px solid var(--line);
}
[role="tab"] { flex: none; padding: 0.75rem 1rem; white-space: nowrap; }
.tabs-arrow {
position: absolute; inset-block: 0; margin: auto 0;
inline-size: 2rem; block-size: 2rem;
}
.tabs-arrow[data-edge="end"] { inset-inline-end: 0; }Tokens
Component tokens
| Part | Property | Token | Value |
|---|---|---|---|
| Tab, unselected | Text colour | ink-soft | #4b4b55 |
| Tab, selected and hover | Text colour | ink | #16161a |
| Bar, selected | Colour | lilac | #5e548e |
| Tablist line, bar on hover | Colour | line | #d7d7dc |
| Focus ring | Colour | — | #1b4fd1 |
| Scroll arrow | Border · fill | line · — | #d7d7dc · #ffffff |
Guidelines
Controls by scenario and context
Each row is a use case, drawn with the real component at half size.
- PanelOutside the tablist: the tabpanel it controls, in the Tab order
Zoom 400% · 320 CSS px. At 200% text size a horizontal strip scrolls and a vertical column wraps its labels: nothing is truncated.
Product details
Horizontal · automatic
- Desktop · 640 px and upFull width
Tablist · 3 tabs · panel below
Panel
aria-label="Product details". Arrows show the panel at once: the panels are already in the page.
- Smaller viewport · 400–639 pxNarrow column or card
Tablist · 3 tabs
Panel
Same; labels never truncated.
- Zoom 400% · 320 CSS pxReflow
Tablist · 3 tabs
Short labels fit. A strip that does not fit scrolls in its wrapper, never onto a second row.
Account settings
Vertical · automatic
- Desktop · 640 px and upFull width
Vertical tablist beside the panel
Panel
aria-orientation="vertical": ArrowUp / ArrowDown.
- Smaller viewport · 400–639 pxNarrow column or card
Column beside the panel
The column stays beside its panel at every width.
- Zoom 400% · 320 CSS pxReflow
Column; labels wrap
Labels wrap inside the column; keys unchanged.
Code per framework
Horizontal · manual
- Desktop · 640 px and upFull width
Tablist · 3 tabs · panel below
Panel
Manual: arrows move, Enter / Space selects. tabindex follows selection, not the arrows.
- Smaller viewport · 400–639 pxNarrow column or card
Tablist · 3 tabs
Same.
- Zoom 400% · 320 CSS pxReflow
Tablist · 3 tabs
Short labels fit.
Many tabs
Scrollable tablist
- Desktop · 640 px and upFull width
Tabs · more if room
A wrapper scrolls, not the ul. An arrow at the end that has more tabs.
- Smaller viewport · 400–639 pxNarrow column or card
Selected tab in view · the rest scroll
The selected tab is scrolled into view.
- Zoom 400% · 320 CSS pxReflow
Selected tab in view · the rest scroll
Many tabs at 320 px: consider a vertical tablist or a select.
Right-to-left
dir="rtl"
- Desktop · 640 px and upFull width
Tablist mirrored
Logical properties. ArrowLeft goes to the next tab, ArrowRight to the previous.
- Smaller viewport · 400–639 pxNarrow column or card
Tablist mirrored
Same.
- Zoom 400% · 320 CSS pxReflow
Tablist mirrored
Same.
Accessibility
Keyboard
| Key | Automatic activation (default) | Manual activation |
|---|---|---|
| ArrowRight · ArrowLeft | Next or previous tab, wrapping: focus and select | Focus only |
| Home · End | First or last tab: focus and select | Focus only |
| Enter · Space | Select the focused tab. preventDefault on Space or the page scrolls | Same |
| Vertical | ArrowUp / ArrowDown replace Left / Right | Same |
| Right-to-left | ArrowLeft goes to the next tab, ArrowRight to the previous | Same |
Focus
| Moment | Where focus goes |
|---|---|
| Tab into the tablist | The selected tab, the only one with tabindex="0" |
| Tab out and back, manual | The selected tab, not the one an arrow reached |
| Tab from the selected tab | The panel (tabindex="0"), then its own controls. Shift + Tab goes back |
| A tab is activated | Focus stays on the tab |
| Tabs are added | New ids from a counter that only climbs, so none repeats |
| An overflowing tablist | The strip scrolls so the selected tab is in view, clear of the arrows. Only the strip moves |
Roles and properties
The accessibility tree of Chromium and WebKit. No screen reader was run: listen in NVDA, JAWS and VoiceOver.
| Part | Exposed |
|---|---|
| Tablist | tablist, named by the label, with its orientation |
| Tab | tab, “selected”, “1 of 3”, named by its visible text |
| Tab panel | tabpanel, named by its tab |
| List, scroll arrow | None: no list or listitem is left, and the arrow is aria-hidden |
Support
| Browser | Checked |
|---|---|
| Chromium | Yes |
| WebKit (Safari) | Yes |
| Firefox | No |
WCAG criteria
| Criterion | Met by |
|---|---|
| 1.3.1 Info and Relationships (A) | tablist, tab and tabpanel, wired by aria-controls and aria-labelledby |
| 1.3.2 Meaningful Sequence (A) | Tabs in DOM order, the panel right after the tablist |
| 1.4.1 Use of Color (A) | A bold label and a bar, not colour alone |
| 1.4.3 Contrast (Minimum) (AA) | Unselected 8.62:1, selected 18.04:1 |
| 1.4.10 Reflow (AA) | Only the wrapper around the tablist scrolls at 320 px |
| 1.4.11 Non-text Contrast (AA) | The bar 6.70:1 and the focus ring 6.81:1 |
| 2.1.1 Keyboard (A) | Arrows, Home, End, Enter and Space, Tab and Shift + Tab |
| 2.4.3 Focus Order (A) | A roving tabindex that follows selection; Tab to the panel |
| 2.4.7 Focus Visible (AA) | A 3px ring on :focus-visible |
| 2.4.11 Focus Not Obscured (Minimum) (AA) | The focused tab stays in view, clear of the arrows |
| 2.5.3 Label in Name (A) | The visible text of the tab is its name |
| 2.5.8 Target Size (Minimum) (AA) | Tabs over 24 × 24 CSS px, arrows 32 × 32 |
| 3.2.1 On Focus (A) | Showing a panel changes content, not context |
| 4.1.2 Name, Role, Value (A) | aria-selected kept live, no leftover list |
Related
This section is still in progress.
References
This section is still in progress.