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

Keyboard navigation

A listbox-like list on a deliberately tall page. Arrow keys, Home and End move the selection and focus (roving tabindex) and are cancelled with prevent(), so the page does not scroll; a lock makes the list's key rule inactive, and the keys scroll the page again. A nested region inside one item has its own keys, and a filter field in the same section keeps its native keys.

Live previewreal runtime
View source

How it works

  • key()lines 15, 16, 21, 26Runs actions on keydown while focus is on or inside the element.
  • prevent()lines 15, 16, 21Event 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.
  • :when()line 14Matches elements for which the condition is true, with states resolved from that element.
  • cycle()line 15Moves a state to the next value in a list, wrapping around. In an :on() rule it is the mutation --mode: cycle(light, dark).

key() + prevent() (0.7.0) + focus: (0.6.0) on a page that really scrolls, conditional event rules, nested key regions and text fields inside a keyboard-controlled section.

Left to native HTML and CSS

Page scrolling (PageDown, Space, End when not handled), the text field’s caret keys, role="listbox"/role="option" semantics as markup.

What would normally need JavaScript

Keydown handlers with preventDefault(), roving tabindex bookkeeping and focus calls.

Limitations

  • Only for focus inside the list. If nothing is focused, key() falls back to the last-clicked element: actions run, but nothing is cancelled, so the page may scroll.
  • Keys typed in the filter: the filter is not inside the list, so arrows and Home/End stay with the field. Escape (the section’s rule) still runs there: it unlocks the list, without moving focus.
  • The nested region inside an option is a test fixture: interactive content inside role="option" is not a recommended pattern.
  • macOS: Home/End do not move a text field’s caret (platform behavior).

Accessibility

Roving tabindex means one Tab stop for the list. ARIA listbox semantics (aria-selected) are real attributes; aria-activedescendant is not used (focus moves to the options). Not verified with screen readers.

Implementation

2 files · 26 lines
style.cssreactive
/* ---- state ---------------------------------------------------------------------------------- */.nav { --i: state(1); --locked: state(false); --q: state(1); }.nav .lock { bind: --locked; }.nav .status .sel { text: var(--i); }.nav .inner .q { text: var(--q); }/* ---- the list: a roving tabindex, selection, focus --------------------------------------------- */.nav .opt { attr: aria-selected "false"; attr: tabindex "-1"; focus: false; }.nav .opt:nth-var(--i) { attr: aria-selected "true"; attr: tabindex "0"; focus: true; }.nav .opt { on: click(set(--i, sibling-index())); }/* Conditional events: only while unlocked does the list handle (and cancel) these keys. Locked:   no rule applies, so the keys keep their native behavior (the page scrolls). */.nav:when(--locked == false) .list {  on: key(ArrowDown, prevent(), cycle(--i, 1, 2, 3, 4, 5, 6)), key(ArrowUp, prevent(), cycle(--i, 6, 5, 4, 3, 2, 1)),      key(Home, prevent(), set(--i, 1)), key(End, prevent(), set(--i, 6));}/* A nested region inside item 5: ArrowLeft/Right are its own (the list ignores them); ArrowDown   still reaches the list's handler (bubbling) and is cancelled there. */.nav .inner { on: key(ArrowRight, prevent(), increment(--q)), key(ArrowLeft, prevent(), decrement(--q)); }/* The section handles Escape (unlock; no default to cancel), also while typing in the filter:   non-printable keys run their actions in fields too, but no focus: depends on --locked, so focus   stays in the field. The filter is not inside the list: its arrows, Home and End stay native. */.nav { on: key(Escape, set(--locked, false)); }
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/keyboard-navigation/. 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

Nested state and cascade

Three nested boxes, each owning --n. Buttons change the nearest owner (or an explicit owner with closest()); a paragraph without its own state reads the nearest owner's value. Labels, chips and markers show which declaration the cascade chose: specificity, a :when() condition, @layer order and !important.

0.8 syntaxtext:attr::when()

Form interactions

An order form: a bound name with live text, pickup or shipping (the address field appears and becomes required for shipping), a quantity with native min/max validity, a terms checkbox that enables submit, native :valid/:invalid/:user-invalid styling and native reset. method="dialog": nothing is sent or stored.

0.8 syntaxbind:text:attr::when()

History and motion

A level from 0 to 20 with Up, Down, +5 and Reset. The previous value and the direction of the last change are shown as text; the bar width transitions natively; the value pulses (or dips) on every change via :changed(). Reduced motion turns both off.

0.8 syntaxincrement()decrement()set()previous()