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.
Also known as: wizard, stepped form.
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 response contract under api/recipes/multi-step-form/.
One demo-only liberty: the stateless docs endpoint threads the draft
through hidden inputs on each step — a real server keeps the draft
server-side (a session, a draft row, a signed cookie), which is what
the “no hidden fields accumulating across steps” rule is about.
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 response contract
Section titled “Server response contract”| 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) |
The 422 needs the standard one-line beforeSwap allowance from
mutating form.
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.
Progressive enhancement
Section titled “Progressive enhancement”Real step URLs (/signup/1, /signup/2, …) + method/action +
formnovalidate mean the wizard degrades to classic pages:
- Without JavaScript each named submit (
nav=next/nav=back) posts the same body natively, and the server answers with the full page at the next (or same, on errors) step — the htmx layer only turns those page loads into region swaps. - Back still never validates:
formnovalidateis native, not htmx. - Refresh and deep links land on the server’s idea of the current step either way, because the step lives in the URL and the draft lives on the server.
- CSRF: the meta convention covers the htmx path; the native path needs your framework’s hidden field, as in mutating-form.
- 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.