Skip to content

Component

tabs

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and tabs.css. No scripts.

Source

Copy both files, or run npx nojsui add tabs.

Source for tabs
<!-- .sk-tabs — a radio group presented as tabs (ADR 0003).

     Screen readers announce radio semantics, not tab semantics. That is
     deliberate and documented in the README: the behaviour a screen-reader user
     is promised — one tab stop, arrow keys to move, selection is activation —
     is exactly what a radio group delivers natively, and claiming role="tab"
     would promise ARIA behaviour that CSS cannot implement.

     The <fieldset> and <legend> are the native grouping for radios, so no ARIA
     is needed to give the group a name. -->
<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="sk-tabs-demo"
      id="sk-tabs-demo-cli"
      checked
    />
    <label class="sk-tabs__tab" for="sk-tabs-demo-cli">CLI</label>

    <input
      class="sk-tabs__radio"
      type="radio"
      name="sk-tabs-demo"
      id="sk-tabs-demo-copy"
    />
    <label class="sk-tabs__tab" for="sk-tabs-demo-copy">Copy &amp; paste</label>

    <input
      class="sk-tabs__radio"
      type="radio"
      name="sk-tabs-demo"
      id="sk-tabs-demo-registry"
    />
    <label class="sk-tabs__tab" for="sk-tabs-demo-registry">Registry</label>

    <!-- One underline for the whole group; it anchors itself to whichever
         label is checked. Decorative, hence aria-hidden. -->
    <span class="sk-tabs__underline" aria-hidden="true"></span>
  </div>

  <div class="sk-tabs__panels">
    <section class="sk-tabs__panel">
      <p class="sk-tabs__text">
        <code>npx nojsui add tabs</code> copies the two files into your project.
      </p>
    </section>

    <section class="sk-tabs__panel">
      <p class="sk-tabs__text">
        Copy <code>tabs.html</code> and <code>tabs.css</code> straight out of the
        repository. There is nothing else to wire up.
      </p>
    </section>

    <section class="sk-tabs__panel">
      <p class="sk-tabs__text">
        Fetch <code>/r/tabs.json</code> from the registry if you are generating
        components as part of a build.
      </p>
    </section>
  </div>
</fieldset>
/* .sk-tabs — tabs built on a radio group (ADR 0003).

   A radio group already is the tab keyboard contract: one tab stop, arrow keys
   move between options, and moving the selection *is* activating it. The only
   thing it does not provide is the ARIA role names, and CSS cannot supply those
   honestly — role="tab" promises behaviour (aria-selected toggling, roving
   tabindex) that no stylesheet can implement. The README says exactly what a
   screen reader announces instead.

   Specs:
   - anchor-scope ........... https://drafts.csswg.org/css-anchor-position-1/#anchor-scope
   - position-area .......... https://drafts.csswg.org/css-anchor-position-1/#position-area
   - anchor-size() .......... https://drafts.csswg.org/css-anchor-position-1/#anchor-size-fn */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins. Note that --sk-tabs-active is an
   anchor name, not a knob — anchor names share this namespace by the naming
   rules, so no knob may reuse one. */
.sk-tabs {
  --_accent: var(--sk-tabs-accent, var(--sk-color-accent));
  --_tab-fg: var(--sk-tabs-tab-fg, var(--sk-color-text-muted));
  --_tab-fg-hover: var(--sk-tabs-tab-fg-hover, var(--sk-color-text));
  --_divider: var(--sk-tabs-divider-color, var(--sk-color-border));
  --_underline-size: var(--sk-tabs-underline-size, calc(var(--sk-border-width) * 2));
  --_text-fg: var(--sk-tabs-text-fg, var(--sk-color-text-muted));

  margin: 0;
  padding: 0;
  border: 0;

  /* A fieldset is min-content by default; let it fill its container. */
  min-inline-size: 0;
}

/* The legend names the group for assistive tech. The visible tabs are the
   labels, so it does not need to be seen. */
.sk-tabs__legend {
  /* Out of flow and clipped away, but still in the accessibility tree — it is
     what gives the radio group its name. No sizing needed: absolute
     positioning keeps it out of the layout and clip-path removes the paint. */
  position: absolute;
  clip-path: inset(50%);
  white-space: nowrap;
}

.sk-tabs__list {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  gap: var(--sk-space-xs);
  border-block-end: var(--sk-border-width) solid var(--_divider);
}

/* The radios drive everything and are never seen — but they stay in the
   document and in the accessibility tree, because they are the control. Focus
   lands on the radio, so the ring is drawn on its label. */
.sk-tabs__radio {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.sk-tabs__tab {
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-sm);
  color: var(--_tab-fg);
  font-size: var(--sk-text-sm);
  font-weight: var(--sk-weight-medium);
  cursor: pointer;
  min-block-size: var(--sk-size-tap-target);
  display: flex;
  align-items: center;
}

.sk-tabs__tab:hover {
  color: var(--_tab-fg-hover);
}

.sk-tabs__radio:checked + .sk-tabs__tab {
  color: var(--_accent);

  /* Fallback underline: a border on the checked label. Correct everywhere,
     just static. The shared sliding element below replaces it where anchor
     positioning is available. */
  box-shadow: inset 0 calc(-1 * var(--_underline-size)) 0 0 var(--_accent);
}

/* Focus is on the visually hidden radio, so mirror the ring onto its label. */
.sk-tabs__radio:focus-visible + .sk-tabs__tab {
  outline: var(--sk-ring-width) solid var(--sk-color-focus);
  outline-offset: calc(var(--sk-ring-offset) * -1);
  border-radius: var(--sk-radius-sm);
}

/* ---------------------------------------------------------------------------
   The underline

   Fallback first: a border on the checked label. Correct everywhere, just
   static. The enhancement is one shared element that anchors itself to
   whichever label is checked, so it slides between them.

   anchor-scope is what keeps this copy-pasteable — the anchor name is scoped to
   each .sk-tabs__list, so every instance on a page can use the same name
   instead of needing a generated one per demo.
--------------------------------------------------------------------------- */

.sk-tabs__underline {
  display: none;
}

@supports (anchor-scope: --x) and (position-area: block-end) {
  .sk-tabs__list {
    anchor-scope: --sk-tabs-active;
  }

  .sk-tabs__radio:checked + .sk-tabs__tab {
    anchor-name: --sk-tabs-active;

    /* the shared underline takes over from the per-label border */
    box-shadow: none;
  }

  .sk-tabs__underline {
    display: block;
    position: absolute;
    position-anchor: --sk-tabs-active;
    position-area: block-end;
    inline-size: anchor-size(inline);
    block-size: var(--_underline-size);
    background: var(--_accent);
  }

  @media (prefers-reduced-motion: no-preference) {
    .sk-tabs__underline {
      transition:
        inset-inline-start var(--sk-motion-fast) var(--sk-ease-out),
        inline-size var(--sk-motion-fast) var(--sk-ease-out);
    }
  }
}

/* ---------------------------------------------------------------------------
   Panels

   One :has() per rule, not a chain (CLAUDE.md forbids :has() state machines).
   Six tabs are supported; a seventh needs another pair of rules, which is a
   deliberate ceiling — a tab strip that long wants a different component.
--------------------------------------------------------------------------- */

.sk-tabs__panel {
  display: none;
  padding-block: var(--sk-space-md);
}

.sk-tabs:has(.sk-tabs__radio:nth-of-type(1):checked) .sk-tabs__panel:nth-of-type(1),
.sk-tabs:has(.sk-tabs__radio:nth-of-type(2):checked) .sk-tabs__panel:nth-of-type(2),
.sk-tabs:has(.sk-tabs__radio:nth-of-type(3):checked) .sk-tabs__panel:nth-of-type(3),
.sk-tabs:has(.sk-tabs__radio:nth-of-type(4):checked) .sk-tabs__panel:nth-of-type(4),
.sk-tabs:has(.sk-tabs__radio:nth-of-type(5):checked) .sk-tabs__panel:nth-of-type(5),
.sk-tabs:has(.sk-tabs__radio:nth-of-type(6):checked) .sk-tabs__panel:nth-of-type(6) {
  display: block;
}

.sk-tabs__text {
  margin: 0;
  color: var(--_text-fg);
  font-size: var(--sk-text-sm);
  max-inline-size: var(--sk-size-measure);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
:has()widely10510512115.410512115.4
:focus-visiblewidely86868515.4868515.4
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Anchor positioning3 of its partsnewly1311311472613114726

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 &amp; 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" and role="tablist" would change the announcement without changing the behaviour, and would then owe aria-selected toggling, 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).

PropertyDefaultControls
--sk-tabs-accent--sk-color-accentSelected tab text and the underline
--sk-tabs-tab-fg--sk-color-text-mutedUnselected tab text
--sk-tabs-tab-fg-hover--sk-color-textTab text on hover
--sk-tabs-divider-color--sk-color-borderRule under the tab strip
--sk-tabs-underline-sizecalc(var(--sk-border-width) * 2)Thickness of the selected-tab underline
--sk-tabs-text-fg--sk-color-text-mutedPanel 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

KeyBehaviour
TabEnters the group, landing on the selected tab; the group is one tab stop
Arrow keysMove the selection, which switches the panel
Tab againLeaves the group

Arrow keys are not identical across engines. This is native radio behaviour, not something this component sets or can change — measured:

EngineLTR next / prevRTL RightRTL Left
ChromiumRight, Down / Left, Up — wrapspreviousnext
GeckoRight, Down / Left, Up — wrapspreviousnext
WebKitRight, Down / Left, Up — does not wrapnextno 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 opacity and pointer-events, never display: 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 usedBaseline statusBehavior without it
:has()widelyPanels never switch. This is the mechanism, not polish.
:focus-visiblewidelyNo focus ring on the selected tab; the group is still operable.
Anchor positioninglimitedThe underline is a border on the checked tab instead of a sliding element. Visually static, functionally identical.