Multi-step form
multi-step-form is the blessed wizard: the server owns the current
step and a draft of everything entered. The client is one region
(whole-step outerHTML swaps), one form per step, and an
hc-stepper the server
re-renders with every response — no client-side step state, no hidden
fields accumulating across steps, no JavaScript beyond htmx. Stable
under the
markup versioning policy.
Live demo
Section titled “Live demo”Walk the wizard below — Next validates the step (natively via
required, and authoritatively on the server, whose 422 answers a
field-errors fragment), Back never validates even with a blank
field, and the final step redirects to a plain confirmation page with a
link back here. The endpoint is a namespaced demo implementation of the
server responses under api/recipes/multi-step-form/.
The markup (a middle step)
Section titled “The markup (a middle step)”<section id="wizard"> <ol class="hc-stepper"> <li class="hc-stepper__step" data-state="complete"> <span class="hc-stepper__marker" aria-hidden="true">✓</span> <span class="hc-stepper__label">Account <span class="hc-sr-only">(completed)</span></span> </li> <li class="hc-stepper__step" aria-current="step"> <span class="hc-stepper__marker" aria-hidden="true">2</span> <span class="hc-stepper__label">Profile</span> </li> <li class="hc-stepper__step"> <span class="hc-stepper__marker" aria-hidden="true">3</span> <span class="hc-stepper__label">Review</span> </li> </ol>
<form method="post" action="/signup/2" data-hx-post="/signup/2" data-hx-target="#wizard" data-hx-swap="outerHTML" data-hx-disabled-elt="find button[type=submit]"> <div id="wizard-errors"></div> …step 2 fields, pre-filled from the draft… <button class="hc-button" type="submit" name="nav" value="back" formnovalidate>Back</button> <button class="hc-button" data-variant="primary" type="submit" name="nav" value="next">Next</button> </form></section>Both nav directions are named submits of the same form
(name="nav"), so htmx and the native no-JS path send identical
bodies, and the current step’s entries always travel with the
navigation.
Server responses
Section titled “Server responses”| Case | Response |
|---|---|
nav=next, valid | 200 — the complete #wizard fragment for the following step: stepper re-rendered (this step now ✓), fields pre-filled from the draft |
nav=back | 200 — the previous step’s fragment; the posted fields were merged into the draft without validation |
nav=next, invalid | 422 + HX-Retarget: #wizard-errors + HX-Reswap: innerHTML with the field-errors fragment — the step is not re-rendered, the user’s in-progress values stay untouched |
final nav=next | the mutating-form completion: 204 + HX-Redirect (htmx) / 303 Location (no-JS) |
Step URLs are real (/signup/1, /signup/2, …): refresh and deep
links land on the server’s idea of the current step, and without
JavaScript the wizard is simply classic pages.
Accessibility
Section titled “Accessibility”The stepper announces state structurally (<ol> +
aria-current="step" + sr-only (completed)); formnovalidate keeps
keyboard users un-trapped; the 422 path preserves field-errors’ focus
management; put the step heading first in each fragment so reading
order restarts sensibly after a swap.
- Draft storage (session, draft row) is your framework’s concern — the contract only requires lossless back-then-next round-trips and unvalidated drafts.
- File inputs don’t round-trip through drafts — upload files separately (file-upload) and reference the stored upload in the draft.
- Completed stepper steps may be links to their step URLs if the flow allows revisiting.
The claims here — the stepper re-render, the lossless draft
round-trip, back escaping an invalid step, the value-preserving 422,
completion — are pinned by a real-htmx browser test
(test-browser/multi-step-form.spec.mjs).
Related
Section titled “Related”- Stepper — the zero-JS indicator.
- Mutating form recipe — the completion branching and the
422allowance. - Field errors recipe — the validation fragment.