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