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.

The full ARIA shape. Deck styles it; the switching needs your JavaScript.

2,481 rows, last run at 04:12 UTC.

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

Narrow the window and drag the strip sideways
<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 rest tabindex="-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-selected moves 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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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 with aria-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" and aria-labelledby pointing at its tab, and each tab needs aria-controls pointing back. The examples here show both directions.
  • Hide inactive panels with hidden, not opacity or 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-label on the role="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 .tab on an <a> with aria-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 .grid or 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. .tabbar at the bottom, or an accordion, both fit a small screen better.