Component
tabs
Demo
tokens.css,
base.css and tabs.css. No scripts.
Source
Copy both files, or run npx nojsui add tabs.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| :has() | widely | 105 | 105 | 121 | 15.4 | 105 | 121 | 15.4 |
| :focus-visible | widely | 86 | 86 | 85 | 15.4 | 86 | 85 | 15.4 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Anchor positioning3 of its parts | newly | 131 | 131 | 147 | 26 | 131 | 147 | 26 |
Usage
A <fieldset>, a radio per tab, a label per radio, and a panel per tab in the
same order.
<fieldset class="sk-tabs">
<legend class="sk-tabs__legend">Install method</legend>
<div class="sk-tabs__list">
<input class="sk-tabs__radio" type="radio" name="install" id="install-cli" checked />
<label class="sk-tabs__tab" for="install-cli">CLI</label>
<input class="sk-tabs__radio" type="radio" name="install" id="install-copy" />
<label class="sk-tabs__tab" for="install-copy">Copy & paste</label>
<span class="sk-tabs__underline" aria-hidden="true"></span>
</div>
<div class="sk-tabs__panels">
<section class="sk-tabs__panel">…</section>
<section class="sk-tabs__panel">…</section>
</div>
</fieldset>
Panels are matched to tabs by order, so the nth radio shows the nth
<section>. Up to six tabs; a seventh needs another rule pair, which is a
deliberate ceiling — a strip that long wants a different component.
What a screen reader actually announces
ADR 0003 chose a radio group and required this to be measured rather than assumed. Measured in Chromium, WebKit and Gecko, the computed accessibility tree is:
group "Install method"
radio "CLI" [checked]
radio "Copy & paste"
radio "Registry"
Radio semantics, not tab semantics. A screen-reader user hears “Install method, group” and “CLI, radio button, selected, 1 of 3” — not “tab, selected”.
That is the honest trade, and it is the right one:
- The behaviour the ARIA tabs pattern promises — one tab stop, arrow keys to move, selection is activation — is exactly what a radio group does natively.
- Adding
role="tab"androle="tablist"would change the announcement without changing the behaviour, and would then owearia-selectedtoggling, which no stylesheet can do. That is faking semantics, which this kit does not do. - A radio group is also a truthful description: the user is choosing which one of several views to show.
Still outstanding: ADR 0003 says that if VoiceOver and NVDA reality is unacceptable, the ADR is superseded. Computed ARIA is not the same as what a screen reader says out loud, so that decision is not final until the manual pass happens.
Theming
Set any of these anywhere above the component — :root, a section wrapper, or
one instance. tabs.css only ever reads them, so the nearest declaration wins
(ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-tabs-accent | --sk-color-accent | Selected tab text and the underline |
--sk-tabs-tab-fg | --sk-color-text-muted | Unselected tab text |
--sk-tabs-tab-fg-hover | --sk-color-text | Tab text on hover |
--sk-tabs-divider-color | --sk-color-border | Rule under the tab strip |
--sk-tabs-underline-size | calc(var(--sk-border-width) * 2) | Thickness of the selected-tab underline |
--sk-tabs-text-fg | --sk-color-text-muted | Panel prose |
:root { --sk-tabs-underline-size: 3px; }
--sk-tabs-underline-size feeds both the static fallback underline and the
sliding one, so the two cannot drift.
--sk-tabs-active is not a knob — it is the anchor name the sliding underline
targets. Anchor names share the --sk-<component>-* namespace by the naming
rules, so setting it does nothing useful and may break the slide.
Keyboard contract
| Key | Behaviour |
|---|---|
Tab | Enters the group, landing on the selected tab; the group is one tab stop |
Arrow keys | Move the selection, which switches the panel |
Tab again | Leaves the group |
Arrow keys are not identical across engines. This is native radio behaviour, not something this component sets or can change — measured:
| Engine | LTR next / prev | RTL Right | RTL Left |
|---|---|---|---|
| Chromium | Right, Down / Left, Up — wraps | previous | next |
| Gecko | Right, Down / Left, Up — wraps | previous | next |
| WebKit | Right, Down / Left, Up — does not wrap | next | no move |
So on Safari the selection stops at the ends rather than cycling, and the arrow keys are not mirrored in a right-to-left document. Everything still reachable; just not uniform. Documented here rather than papered over, because a keyboard contract that quietly differs per engine is worse than one that says so.
Verified manually in VoiceOver and NVDA: not yet — do this before the component is marked done, and treat the ADR 0003 question as open until then.
The sliding underline
One shared element anchors itself to whichever label is :checked, so it slides
between tabs and sizes itself to each one — verified tracking the checked label
to within 2px in all three engines, LTR and RTL.
anchor-scope is what keeps this copy-pasteable. ADR 0003 anticipated
generated per-instance anchor names (--sk-tabs-<id>). They turn out to be
unnecessary: scoping the name to each .sk-tabs__list means every instance on a
page can use the same name without colliding. Same markup twice, no per-demo
CSS.
Without anchor positioning the underline is a border on the checked tab — static instead of sliding, correct everywhere.
Accessibility notes
- The
<legend>names the group. It is clipped rather than removed, so it stays in the accessibility tree. Without it the group is announced unnamed. - The radios stay in the accessibility tree. They are hidden with
opacityandpointer-events, neverdisplay: none— they are the control. - Focus lands on the radio; the ring is drawn on its label. They are adjacent, so this looks like one control.
- Hidden panels are
display: none, so their content is out of the accessibility tree rather than read as part of the page. - The underline is
aria-hidden. It is decoration.
Degradation
Baseline column from support.json — regenerate with pnpm support.
| Feature used | Baseline status | Behavior without it |
|---|---|---|
:has() | widely | Panels never switch. This is the mechanism, not polish. |
:focus-visible | widely | No focus ring on the selected tab; the group is still operable. |
| Anchor positioning | limited | The underline is a border on the checked tab instead of a sliding element. Visually static, functionally identical. |