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

Multi-step wizard

Four steps (Introduction, Preferences, Configuration, Review) plus a completion panel, with Back, Next and Finish, a step indicator, a progress bar and a "Step N of 4" status. Two independent instances. Nothing is submitted.

Live previewreal runtime
View source

How it works

  • focus:lines 26, 27Moves 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).
  • attr:lines 14, 15, 16, 43Derives one allowlisted HTML attribute of the matched element, such as disabled or aria-expanded, from a reactive value.
  • increment()line 14Adds a step to a number or dimension state. In an :on() rule it is the mutation --count: increment(1).
  • decrement()line 15Subtracts a step from a number or dimension state. In an :on() rule it is the mutation --count: decrement(1).

State-driven navigation through sibling panels. Native constraint validation (required, :invalid) gates the Next button, without a validation engine.

Left to native HTML and CSS

  • Validation: required, minlength and :invalid. :has() checks the current panel for invalid fields; the browser’s cascade picks the blocking on: rule at click time.
  • Display and motion: display: none for hidden panels (removing them from the tab order and the accessibility tree), the progress transition, prefers-reduced-motion, and counters for the status text.

What would normally need JavaScript

Tracking the current step, showing one panel, and the Next/Back logic. Native alternatives exist only partly:

  • Radio-button tricks (:has(#step2:checked)) can show one panel, but the controls are then labels pretending to be buttons, and validation cannot block them.
  • <details> is not linear.

Limitations

  • Focus (0.6.0, focus:). When a click or key press changes the step, focus moves to the new step’s heading (tabindex="-1" in the HTML; the library never adds it). This covers Next, Back, Finish and Start again. Page load, scripts and native form reset never move focus. Reaching step 4 still disables the clicked Next, but focus is already on the step 4 heading. Rapid navigation focuses only the final step (tested).
  • aria-current="step" (0.5.3). Set on the current step’s <li> only; the other steps have no attribute. Completed steps still get a “completed” alternative text from generated content.
  • Rapid clicks. Back and Next are natively disabled at the bounds, written in the same task as the click. 9 synchronous Next clicks stop at step 4 (tested). The 0.5.1 clamp rules are gone. Finish is disabled outside step 4, which also stops a scripted click on the hidden button.
  • Validation reaches into nested wizards. :has(… :invalid) sees invalid fields in a wizard nested inside the current panel, as native constraint validation would.
  • No generic per-step validation. The gate covers what native validation expresses.
  • Child combinators (>) are needed everywhere, so rules do not reach into a nested wizard.
  • The validation gate is still an action. A rule like .wizard:has(… :invalid) .next { attr: disabled true } is valid, but attr: re-reads only on state changes. Typing that makes a field valid changes no state, so the button would stay disabled. The gate keeps the conditional on: rule and the hint.

Accessibility

  • Real buttons, labels and visible focus.
  • The status (“Step N of 4” / “Completed”) is DOM text.
  • Hidden panels and Finish (outside step 4) are display: none: neither focusable nor announced (tested). Back and Next at a bound are disabled: announced as unavailable, not focusable.
  • aria-current="step" on the current step (0.5.3, tested).
  • Focus moves to the new step’s heading on every user-initiated step change (0.6.0, tested in Chromium and WebKit). Whether screen readers announce it well is not verified (no screen-reader testing).
  • Missing:
    • the progress bar is aria-hidden.
  • Not tested with screen readers.

Implementation

2 files · 62 lines
style.cssreactive
/* ---- state: the current step (1–4; 5 = done) ---------------------------------------------- */.wizard { --step: state(1); --tried: state(false); }/* Only the current panel is rendered. display: none also removes hidden panels and their   controls from the tab order and the accessibility tree. */.wizard > .panels > .panel { display: none; }.wizard > .panels > .panel:nth-var(--step) { display: block; }/* ---- navigation ------------------------------------------------------------------------------   attr: (0.5.3): Back is natively disabled on step 1, Next on step 4 (where Finish appears).   The attribute is written in the same task as the click, so a burst of clicks cannot leave   1–4. Finish acts only on step 4 (disabled elsewhere, also while not rendered: a script can   still click a display: none button). After Finish, the whole nav is not rendered. */.wizard > .nav > .next   { on: click(increment(--step), set(--tried, false)); attr: disabled when(--step >= 4); }.wizard > .nav > .back   { on: click(decrement(--step), set(--tried, false)); attr: disabled when(--step <= 1); }.wizard > .nav > .finish { on: click(set(--step, 5)); attr: disabled when(--step != 4); }.wizard > .panels > .panel > .restart { on: click(set(--step, 1)); }.wizard:when(--step != 4) > .nav > .finish,.wizard:when(--step == 5) > .nav { display: none; }.wizard > .nav > button:disabled { opacity: 0.45; cursor: not-allowed; }/* ---- focus (focus:, 0.6.0) ---------------------------------------------------------------------   When a user action changes the step, focus moves to the new step's heading (tabindex="-1" in   the HTML makes it programmatically focusable; the library never adds tabindex). Only one   heading is true at a time; page load, scripts and the native reset never move focus. */.wizard > .panels > .panel > h2 { focus: false; }.wizard > .panels > .panel:nth-var(--step) > h2 { focus: true; }.wizard > .panels > .panel > h2:focus:not(:focus-visible) { outline: none; }/* Native validation gates Next: while the current panel has an :invalid field, a more specific   rule replaces the action, and the hint appears. (:has() is evaluated by the browser's cascade   when the click happens.) */.wizard:has(> .panels > .panel:nth-var(--step) :invalid) > .nav > .next { on: click(set(--tried, true)); }.wizard > .hint { display: none; color: #b42318; }.wizard:when(--tried):has(> .panels > .panel:nth-var(--step) :invalid) > .hint { display: block; }/* ---- step indicator, progress and status ------------------------------------------------------ */.wizard > .steps { display: flex; gap: 6px; padding: 0; margin: 0 0 12px; list-style: none; flex-wrap: wrap; }.wizard > .steps > li { padding: 4px 10px; border-radius: 99px; background: #eee; color: #555; font-size: 14px; }.wizard > .steps > li:nth-var(--step) { background: #2563eb; color: #fff; font-weight: 600; }/* aria-current="step" on the current step only (attr:, 0.5.3). The other steps get no   declaration, so their attribute stays absent. */.wizard > .steps > li:nth-var(--step) { attr: aria-current "step"; }.wizard:when(--step > 1) > .steps > li:nth-child(1),.wizard:when(--step > 2) > .steps > li:nth-child(2),.wizard:when(--step > 3) > .steps > li:nth-child(3),.wizard:when(--step > 4) > .steps > li:nth-child(4) { background: #dcfce7; color: #166534; }.wizard:when(--step > 1) > .steps > li:nth-child(1)::before,.wizard:when(--step > 2) > .steps > li:nth-child(2)::before,.wizard:when(--step > 3) > .steps > li:nth-child(3)::before,.wizard:when(--step > 4) > .steps > li:nth-child(4)::before { content: "✓ " / "completed: "; }.wizard > .progress { height: 6px; background: #eee; border-radius: 3px; overflow: hidden; }.wizard > .progress > span { display: block; height: 100%; background: #2563eb;  width: calc((min(var(--step), 5) - 1) / 4 * 100%); transition: width 250ms ease; }@media (prefers-reduced-motion: reduce) { .wizard > .progress > span { transition: none; } }/* Real DOM text (text:, 0.5.1); "Completed" is static HTML shown at the end. */.wizard > .status, .wizard > .done { margin: 8px 0; color: #555; }.wizard > .status > .n { text: var(--step); }.wizard > .done, .wizard:when(--step == 5) > .status { display: none; }.wizard:when(--step == 5) > .done { display: block; }
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/multi-step-wizard/. 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

Live form preview

A profile form (display name, role, accent, email notifications) and a preview card that follows every change immediately. Native reset restores everything. There is no backend: the Save button is disabled, so neither the button nor Enter submits, and nothing is stored (the test checks storage and cookies).

0.8 syntaxbind::when()attr:text:

Score history

Two independent scoreboards. Each shows the current score, the previous score, the last change (signed), the direction (up or down) and a short visual bump or dip on every change. +1, −1 and Reset.

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

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.

0.8 syntax:nth-var()prevent()key()focus: