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

Keyboard playground

A focusable pad moved with the arrow keys (Escape centers it), focus tracking with focusin/focusout, nested regions that show how one key press reaches several handlers, and a Reset button that is disabled while there is nothing to reset.

Live previewreal runtime
View source

How it works

  • focusin()line 12Seven more events for on:: focusin(), focusout(), reset(), animationend(), animationcancel(), transitionend() and transitioncancel().
  • focusout()line 12Seven more events for on:: focusin(), focusout(), reset(), animationend(), animationcancel(), transitionend() and transitioncancel().
  • prevent()lines 9, 10Event 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.
  • attr:lines 21, 32Derives one allowlisted HTML attribute of the matched element, such as disabled or aria-expanded, from a reactive value.

The existing keyboard triggers: key(), focusin(), focusout(). It shows their scope (the focused element and its ancestors), the propagation model (every matching ancestor runs), and derived attributes reporting focus and position. No new triggers.

Left to native HTML and CSS

  • tabindex="0" makes the pad focusable; :focus-visible shows it.
  • clamp() bounds the dot’s visual position; translate moves it with a transition (off under reduced motion).

What would normally need JavaScript

Key handling, focus tracking and counting. CSS has :focus-within for styling, but no memory of key presses.

Limitations

  • Scrolling (0.7.0). The pad’s arrow keys use prevent(), so they no longer scroll the page while the pad has focus (tested on a scrollable page). Other keys keep their native behavior. Escape has no default to cancel. Space on the pad is not handled and still scrolls.
  • No stopping propagation. Enter inside the inner region increments both counters, by design: key() runs on the focused element and every ancestor with a matching handler. There is no .stop.
  • Enter on a button is also a click. The target button has no click handler, so only the key() handlers run.
  • Unbounded state, bounded view. --x/--y keep counting past the clamped position. A bound would need conditional on: rules for every key. attr: cannot help: keys are not blocked by disabled on a div.
  • Focus. Reactive CSS cannot move focus; Reset disables itself after use, and focus then returns to the page.
  • WebKit does not focus buttons on click; key() falls back to the element last pressed with the pointer.

Accessibility

  • The pad is a focusable group with a label that explains the keys, and a described status line (real DOM text).
  • It is not an ARIA application or slider: do not rely on it for real controls. Screen readers in browse mode may intercept arrow keys. Not tested with screen readers.

Implementation

2 files · 32 lines
style.cssreactive
/* ---- state: owned by the playground ---------------------------------------------------------- */.playground { --x: state(0); --y: state(0); --last: state("none"); --focused: state(false); --outer: state(0); --inner: state(0); }/* ---- the pad: key() for movement, focusin/focusout for focus state -----------------------------   key() runs on the focused element and its ancestors. prevent() (0.7.0) stops the arrow keys from   also scrolling the page while the pad has focus; Escape has no default to cancel. The dot's   position is clamped by CSS to -4…4 steps; the state itself is not bounded (see the README). */.pad {  on: key(ArrowRight, prevent(), increment(--x), set(--last, "ArrowRight")), key(ArrowLeft, prevent(), decrement(--x), set(--last, "ArrowLeft")),      key(ArrowDown, prevent(), increment(--y), set(--last, "ArrowDown")), key(ArrowUp, prevent(), decrement(--y), set(--last, "ArrowUp")),      key(Escape, set(--x, 0), set(--y, 0), set(--last, "Escape")),      focusin(set(--focused, true)), focusout(set(--focused, false));}.pad .dot { translate: calc(clamp(-4, var(--x), 4) * 28px) calc(clamp(-2, var(--y), 2) * 28px); }.playground .x { text: var(--x); }.playground .y { text: var(--y); }.playground .last { text: var(--last); }.playground .focus-text { text: when(--focused, "pad focused", "pad not focused"); }/* attr: (0.5.3): the pad reports focus and its position as data attributes. */.playground .pad { attr: data-focused var(--focused); attr: data-x var(--x); attr: data-y var(--y); }.pad[data-focused="1"] { border-color: #1d4ed8; background: #eef2ff; }/* ---- nested regions: one key press, every matching ancestor runs its own handler ------------- */.outer { on: key(Enter, increment(--outer)); }.inner { on: key(Enter, increment(--inner)); }.outer .outer-n { text: var(--outer); }.inner .inner-n { text: var(--inner); }/* ---- reset: disabled while there is nothing to reset ----------------------------------------- */.playground .reset { on: click(set(--x, 0), set(--y, 0), set(--last, "none"), set(--outer, 0), set(--inner, 0));  attr: disabled when(--x == 0 and --y == 0 and --outer == 0 and --inner == 0 and --last == "none"); }
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-playground/. 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

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.

0.8 syntaxkey()prevent():when()cycle()

Attribute playground

State controls on one side, targets with derived attributes on the other, and a live table that shows the real DOM attribute values (read with CSS :has() selectors, no script).

0.8 syntaxattr:bind:on::when()

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()