Documents v0.9.0 · previews run the real runtime · experimental features are marked exp

Accessible tabs

Two independent tab sets. Each tab reports a real aria-selected, inactive panels are hidden, one tab is disabled in the HTML, and the "Billing" tab is disabled until a checkbox in the first panel is checked.

Live previewreal runtime
View source

How it works

  • :nth-var()lines 9, 21Matches the sibling whose one-based index equals the integer value of a custom property or state, read on the parent.
  • prevent()lines 16, 17, 18Event modifiers for :on(): prevent() cancels the browser's default for the event, stop() ends the event at the element so ancestors do not see it.
  • key()lines 16, 17, 18Runs actions on keydown while focus is on or inside the element.
  • focus:lines 8, 9Moves focus to the matched element when its value goes from false to true because of a user action. In 0.9 this declaration is the focus request: focus: when(--open).

Selection state owned by the tab set and derived onto many elements: aria-selected on every tab, hidden on every panel, disabled on one tab. In 0.5.2 this was impossible: a state could only bind an attribute of its owner.

Left to native HTML and CSS

  • role="tab", role="tabpanel", aria-controls, aria-labelledby are static markup.
  • hidden hides panels natively; [aria-selected="true"] and :disabled style the tabs.
  • Buttons give Enter and Space.

What would normally need JavaScript

Setting aria-selected on the tabs and hidden on the panels for every selection change, and enabling the Billing tab. A radio-button CSS trick can show one panel, but it cannot expose tab semantics.

Limitations

  • The ARIA Tabs keyboard pattern, automatic activation (0.6.0). Roving tabindex (attr: tabindex "0" on the selected tab, "-1" on the others): only the selected tab is in the Tab order, and Tab moves on to its panel. ArrowLeft/ArrowRight (wrapping), Home and End select a tab and move focus to it (key() + focus:). The disabled tabs are skipped; Billing joins the arrow order when enabled. Tested in Chromium and WebKit, except Tab itself, which is checked in Chromium only: WebKit’s default Tab order skips buttons (macOS setting). Not verified with screen readers, so compliance is not claimed beyond the tested keyboard behavior. Manual activation (arrows move focus without selecting) is not expressible: focus follows selection here.
  • Scrolling (0.7.0). The handled keys use prevent(), so a scrollable page does not jump when you move between tabs. Tested with real keys on a scrollable page: Home/End in both engines, arrows in Chromium. Headless WebKit does not scroll on arrow keys at all, so there only defaultPrevented is checked.
  • Positions, not names. --active is a position (sibling-index()). Reordering tabs in the HTML without reordering the panels breaks the pairing.
  • Disabling the active tab. If Billing were active and --billing became false, the active tab would be disabled. Here the checkbox lives in the Profile panel, so that cannot happen. A general fix needs a rule that resets --active.
  • Progressive enhancement: without the script all panels are visible and no tab is marked selected.

Accessibility

  • Real aria-selected on every tab, real hidden on inactive panels: hidden panels are out of the tab order and the accessibility tree (tested).
  • Native disabled on Team (static) and Billing (derived): announced as unavailable, skipped by Tab.
  • Arrow keys, Home/End and roving tabindex are implemented and tested (0.6.0). Missing: Delete (closable tabs, not applicable), screen-reader verification.

Implementation

2 files · 26 lines
style.cssreactive
/* ---- state: the active tab's position (1-based), owned by each tab set ------------------------ */.tabs { --active: state(1); --billing: state(false); }.tabs [role=tab] { on: click(set(--active, sibling-index())); }/* ---- derived attributes (attr:, 0.5.3) ---------------------------------------------------------   Every tab reports aria-selected; the cascade gives "true" to the active one. Every panel is   hidden except the active one. Child combinators keep a nested tab set independent. */.tabs > [role=tablist] > [role=tab] { attr: aria-selected "false"; attr: tabindex "-1"; focus: false; }.tabs > [role=tablist] > [role=tab]:nth-var(--active) { attr: aria-selected "true"; attr: tabindex "0"; focus: true; }/* ---- keyboard (0.6.0): the ARIA Tabs pattern with automatic activation ----------------------------   Roving tabindex (above): only the selected tab is in the Tab order. Arrow keys, Home and End   select another tab, and focus: moves focus to it (a keydown is a user action). prevent() (0.7.0)   stops these keys from also scrolling the page; other keys (PageDown, Space…) keep scrolling. The   key lists skip disabled tabs; they are written per tab set because each set has its own tabs. */#account > [role=tablist] { on: key(ArrowRight, prevent(), cycle(--active, 1, 2)), key(ArrowLeft, prevent(), cycle(--active, 2, 1)), key(Home, prevent(), set(--active, 1)), key(End, prevent(), set(--active, 2)); }#account:when(--billing) > [role=tablist] { on: key(ArrowRight, prevent(), cycle(--active, 1, 2, 3)), key(ArrowLeft, prevent(), cycle(--active, 3, 2, 1)), key(Home, prevent(), set(--active, 1)), key(End, prevent(), set(--active, 3)); }#docs > [role=tablist] { on: key(ArrowRight, prevent(), cycle(--active, 1, 2)), key(ArrowLeft, prevent(), cycle(--active, 2, 1)), key(Home, prevent(), set(--active, 1)), key(End, prevent(), set(--active, 2)); }[role=tabpanel]:focus-visible { outline-offset: 4px; }.tabs > .panels > [role=tabpanel] { attr: hidden true; }.tabs > .panels > [role=tabpanel]:nth-var(--active) { attr: hidden false; }/* "Team" is disabled in the HTML (static). "Billing" is disabled until the checkbox in the   Profile panel is checked: a derived, native disabled. */.tabs .has-billing { bind: --billing; }.tabs .billing { attr: disabled when(--billing, false, true); }
state()Function · hover any red tokenMakes each matching element the owner of an independent state with an initial value.Reference →

Source: the library's examples/accessible-tabs/. style.css from its first section comment; the layout above it runs but is not shown. Written in the 0.8 syntax (on:, bind:, text:, attr:), which 0.9.0 still runs; see Migrating from 0.8 for the 0.9 forms.

Related

Settings panel

A notifications section whose channel options are disabled while notifications are off, a theme and text-size preview, an "Advanced settings" disclosure, and a native reset. Nothing is saved.

0.8 syntaxbind:attr:toggle()set()

Shopping cart

Three cart lines with − / + / Remove, a subtotal per line, the item count and total, an "empty cart" message, and Checkout and "Empty cart" buttons that are disabled while the cart is empty. Local only: Checkout does nothing.

0.8 syntaxincrement()decrement()set()attr:

Notification center

A collapsible list of three sample notifications. Each can be marked read (a toggle with aria-pressed) or dismissed. "Undo dismiss" restores the last dismissed one, "Dismiss all" clears the list, and the unread count updates as real text.

0.8 syntaxtoggle()set():when()attr: