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.
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:disabledwhen(--step>=4);}.wizard>.nav>.back{on:click(decrement(--step),set(--tried,false));attr:disabledwhen(--step<=1);}.wizard>.nav>.finish{on:click(set(--step,5));attr:disabledwhen(--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:0012px;list-style:none;flex-wrap:wrap;}.wizard>.steps>li{padding:4px10px;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:width250msease;}@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:8px0;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.
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).
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.
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.