Tabs
.tabs is the strip; .tab is one trigger in it. The selected
tab is styled from [aria-selected="true"] — the attribute a screen reader
reads is the same one that draws the underline, so the two cannot disagree.
When to use it
Use tabs when one region of a page shows one of several panels and the reader switches
between them without leaving. If switching should change the URL, use links that look
like tabs — .tab works on an <a>, and
aria-current="page" is then the right attribute rather than
aria-selected.
2,481 rows, last run at 04:12 UTC.
Fourteen runs in the last week.
Schedule, format and destination.
<div class="tabs" role="tablist" aria-label="Export views">
<button class="tab" role="tab" aria-selected="true" aria-controls="dx-p1" id="dx-t1">Overview</button>
<button class="tab" role="tab" aria-selected="false" aria-controls="dx-p2" id="dx-t2" tabindex="-1">History</button>
<button class="tab" role="tab" aria-selected="false" aria-controls="dx-p3" id="dx-t3" tabindex="-1">Settings</button>
</div>
<div class="stack stack-3" style="padding-block-start:var(--space-4)">
<div role="tabpanel" id="dx-p1" aria-labelledby="dx-t1">
<p>2,481 rows, last run at 04:12 UTC.</p>
</div>
<div role="tabpanel" id="dx-p2" aria-labelledby="dx-t2" hidden>
<p>Fourteen runs in the last week.</p>
</div>
<div role="tabpanel" id="dx-p3" aria-labelledby="dx-t3" hidden>
<p>Schedule, format and destination.</p>
</div>
</div>
<div class="tabs" role="tablist" aria-label="Export views">
<button class="tab" role="tab" aria-selected="true" aria-controls="dx-p1" id="dx-t1">Overview</button>
<button class="tab" role="tab" aria-selected="false" aria-controls="dx-p2" id="dx-t2" tabindex="-1">History</button>
<button class="tab" role="tab" aria-selected="false" aria-controls="dx-p3" id="dx-t3" tabindex="-1">Settings</button>
</div>
<div class="stack stack-3" style="padding-block-start:var(--space-4)">
<div role="tabpanel" id="dx-p1" aria-labelledby="dx-t1">
<p>2,481 rows, last run at 04:12 UTC.</p>
</div>
<div role="tabpanel" id="dx-p2" aria-labelledby="dx-t2" hidden>
<p>Fourteen runs in the last week.</p>
</div>
<div role="tabpanel" id="dx-p3" aria-labelledby="dx-t3" hidden>
<p>Schedule, format and destination.</p>
</div>
</div>
Nothing in that example switches panels — Deck ships no tab script. The markup is complete and correct, and clicking a tab does nothing until you wire it. That is the honest state of this component, and the keyboard section explains what wiring it involves.
How the selected tab is drawn
A two-pixel border-block-end in the brand colour, with
margin-block-end: -1px so it sits over the strip's own one-pixel border
rather than beside it. Unselected tabs carry the same border in
transparent, so selecting one changes a colour and never moves anything.
.tab { border-block-end: 2px solid transparent; margin-block-end: -1px; }
.tab[aria-selected="true"], .tab.is-active { color: var(--brand); border-block-end-color: var(--brand); }
.is-active is accepted as an alternative for cases where the ARIA
attribute is not appropriate — a row of links, for instance. Prefer
aria-selected when the element really is a tab, because then the state
exists for a screen reader as well as for the eye.
Overflowing
.tabs is overflow-x: auto with the scrollbar hidden and
white-space: nowrap on each tab, so a strip with more tabs than fit
scrolls sideways rather than wrapping. Tabs that wrap to a second line stop reading as
a strip.
<div class="tabs" role="tablist" aria-label="Many views">
<button class="tab" role="tab" aria-selected="true">Overview</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">History</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">Settings</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">Webhooks</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">Members</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">Audit log</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">Danger zone</button>
</div>
<div class="tabs" role="tablist" aria-label="Many views">
<button class="tab" role="tab" aria-selected="true">Overview</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">History</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">Settings</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">Webhooks</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">Members</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">Audit log</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">Danger zone</button>
</div>
The hidden scrollbar has the same cost here as on
.scroller: the only cue that
more tabs exist is a partially visible one at the edge. With the roving
tabindex pattern below, keyboard focus scrolls the strip automatically —
but a mouse user with no visible scrollbar may not realise there is more.
The keyboard pattern, which is yours
A correct tablist is not just markup. The ARIA authoring practices ask for four things, and Deck's CSS provides none of them:
-
One tab stop for the whole strip. The selected tab has
tabindex="0", the resttabindex="-1"— the roving tabindex. Tab enters the strip and Tab leaves it; it does not walk through seven tabs. - Arrow keys move between tabs. Left and right, or up and down for a vertical strip, wrapping at the ends.
- Home and End jump to the first and last.
-
Selection follows focus, for panels that are cheap to render.
Where a panel is expensive, selection happens on Enter or Space instead, and
aria-selectedmoves separately from focus.
const strip = document.querySelector('[role="tablist"]');
const tabs = [...strip.querySelectorAll('[role="tab"]')];
const select = (i) => {
tabs.forEach((t, n) => {
const on = n === i;
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
});
tabs[i].focus();
};
strip.addEventListener('keydown', (e) => {
const i = tabs.indexOf(document.activeElement);
if (i < 0) return;
const rtl = getComputedStyle(strip).direction === 'rtl';
const prev = rtl ? 'ArrowRight' : 'ArrowLeft';
const next = rtl ? 'ArrowLeft' : 'ArrowRight';
if (e.key === prev) { e.preventDefault(); select((i - 1 + tabs.length) % tabs.length); }
if (e.key === next) { e.preventDefault(); select((i + 1) % tabs.length); }
if (e.key === 'Home') { e.preventDefault(); select(0); }
if (e.key === 'End') { e.preventDefault(); select(tabs.length - 1); }
});
tabs.forEach((t, i) => t.addEventListener('click', () => select(i)));
Note the direction check. Arrow keys in a tablist follow the writing direction, so in an RTL document Left moves to the next tab. Getting that wrong is the most common bug in a hand-written tablist, and it is invisible until somebody tests in Arabic.
Classes
.tabs and .tab are two separate classes rather than a
component root and its member — the extractor sees no shared selector — so there is no
completeness check on this page.
| Class | Layer | What it does | Source |
|---|---|---|---|
.tabs |
deck.components |
The strip. Scrolls sideways rather than wrapping, because tabs that wrap stop reading as a strip. Deck styles the tabs and ships no keyboard behaviour: if you use role="tablist" you must add the roving tabindex and the arrow keys yourself, or the role promises what the page does not do. | 07-components.css:353 |
.tab |
deck.components |
One trigger. Styled from [aria-selected="true"], so the visual state and the accessible state are the same attribute and cannot disagree. The unselected state carries the same transparent border, so selecting one changes a colour and never moves anything. | 07-components.css:365 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--brand |
light-dark(var(--brand-600), var(--brand-400)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--text |
light-dark(var(--ink-900), var(--ink-100)) |
|
--text-muted |
light-dark(var(--ink-600), var(--ink-400)) |
|
--text-sm |
.8125rem |
|
--tap |
44px |
|
--space-1 |
.25rem |
|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--space-4 |
1rem |
|
--dur-1 |
110ms |
Accessibility
-
The visual state and the accessible state are one attribute.
Styling from
[aria-selected="true"]means a tab cannot look selected without being selected. That is the single best thing about this component. -
Deck ships no keyboard behaviour. Without the script above, every
tab is a separate tab stop and arrow keys do nothing — which is a broken tablist,
because the role promises behaviour the page does not deliver. If you use
role="tablist", you must add the keys. -
Or do not use the roles at all. A row of
<a class="tab">links witharia-current="page"is a perfectly good navigation pattern, needs no JavaScript, and promises nothing it does not do. For many "tabs" that is the better answer. -
Each panel needs
role="tabpanel"andaria-labelledbypointing at its tab, and each tab needsaria-controlspointing back. The examples here show both directions. -
Hide inactive panels with
hidden, notopacityor off-screen positioning — otherwise their content stays in the tab order and in the accessibility tree. -
Touch target.
min-block-size: var(--tap)— 44px — so a tab is a comfortable target even though it is visually just text. -
The strip needs a label.
aria-labelon therole="tablist"element, so a reader with several tablists on a page can tell them apart. - Colour is not the only cue — the selected tab gains an underline as well as the brand colour, so it is distinguishable without colour vision.
Right to left
The strip is a flex row with a gap and logical padding, so it fills from the right
under dir="rtl" and scrolls the other way, both handled by the browser.
The underline is border-block-end, which is unaffected by direction.
The one thing that is not automatic is the arrow-key direction in your script — see the note under the keyboard pattern.
<div dir="rtl" class="tabs" role="tablist" aria-label="العروض">
<button class="tab" role="tab" aria-selected="true">نظرة عامة</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">السجل</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">الإعدادات</button>
</div>
<div dir="rtl" class="tabs" role="tablist" aria-label="العروض">
<button class="tab" role="tab" aria-selected="true">نظرة عامة</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">السجل</button>
<button class="tab" role="tab" aria-selected="false" tabindex="-1">الإعدادات</button>
</div>
Reduced motion
A tab transitions its colour and border colour over
110ms; the global reset collapses both to
.01ms under prefers-reduced-motion: reduce. There is no
sliding indicator, which is a deliberate simplification — an underline that travels
between tabs needs JavaScript to measure positions, and it is motion for its own sake.
Printing
src/99-print.css lists .tabs among the chrome that never
prints. A tab strip is a control for choosing which panel to see, and on paper the
choice has already been made — what prints is whichever panel was open.
That means the printed page has no indication of which tab it was. If that matters, put the panel's name in a heading inside the panel as well as in the tab.
Overriding it
@layer app.components {
/* A vertical strip for a settings page */
.tabs-vertical {
flex-direction: column;
border-block-end: 0;
border-inline-end: 1px solid var(--line);
overflow-x: visible;
}
.tabs-vertical .tab {
border-block-end: 0;
border-inline-end: 2px solid transparent;
margin-block-end: 0;
margin-inline-end: -1px;
}
.tabs-vertical .tab[aria-selected="true"] { border-inline-end-color: var(--brand); }
}
A vertical tablist changes the arrow keys too — Up and Down rather than Left and
Right — and needs aria-orientation="vertical" on the strip.
When not to use it
-
Not without the keyboard script.
role="tablist"without arrow keys is a promise the page breaks. Either add the behaviour or drop the roles and use links. -
Not for navigation between pages. Use
.nav-link, or.tabon an<a>witharia-current="page"— but not the tablist roles, which describe in-page panels. -
Not for content the reader needs to compare. Tabs hide everything
but one panel. If two values need to be seen together, use a
.gridor a.table. -
Not for a long list of sections. Seven tabs already scroll. Use an
.accordion, which stacks and needs no keyboard work, or give each section a page. -
Not on a phone, for primary structure. A scrolling strip hides
options off-screen.
.tabbarat the bottom, or an accordion, both fit a small screen better.