Building a multi-step flow
Which component to reach for, how guards and branching work, and when to drop to Stepper and drive the flow yourself.
Last updated
Two components cover this, and picking the wrong one is the usual mistake.
| You have | Reach for |
|---|---|
| Progress the system drives and a person reads | Stepper alone |
| A process a person drives, with bodies and controls | MultiStepForm |
| One question at a time, with its own shortcut keys | Questionnaire |
| Free movement between peer views | Tabs — never a stepper |
An order tracker, a deployment pipeline, an import running on a server: those
are Stepper. It renders no buttons because those flows need none. A signup, a
checkout, an import wizard, a multi-section record editor: those are
MultiStepForm, which composes Stepper and adds the bodies, the action row
and the guards.
The shape
const steps = [
{ id: "account", label: "Account" },
{ id: "company", label: "Business details", when: type === "business" },
{
id: "billing",
label: "Billing",
lock: true,
beforeNext: async () => (await charge()) || "Your bank declined this card.",
},
{ id: "review", label: "Review" },
];
<MultiStepForm steps={steps}>
<MultiStepFormNav aria-label="Signup" />
<MultiStepFormStep id="account">{/* a Field form */}</MultiStepFormStep>
<MultiStepFormStep id="company">{/* another */}</MultiStepFormStep>
<MultiStepFormStep id="billing">{/* another */}</MultiStepFormStep>
<MultiStepFormStep id="review">
{/* a PropertyList — no form at all */}
</MultiStepFormStep>
<MultiStepFormActions />
</MultiStepForm>;The steps array is the flow; each MultiStepFormStep is one body, and only
the current one renders.
It holds no answers, and that is the point
when and satisfied are plain booleans you compute from state you already
have, and a guard is an ordinary function returning a promise. That is what lets
the component ship with no validation library: React Hook Form, Zod, Valibot or a
hand-written check all plug in from your side, and none of them becomes a
dependency of the design system.
beforeNext: async () => {
const result = schema.safeParse(values);
return result.success || result.error.issues[0].message;
};Refusing a move
Return true to pass, or a sentence to refuse. The sentence is rendered above
the action row and tied to the forward button through aria-describedby, so it
reads out with the control it blocks and is still there when the user comes back
to try again.
Weight follows cause, and the component decides it so an app team cannot get it wrong:
- a check that ran and failed → an assertive
Alert, and the step is marked in the rail; - a gate that is simply not satisfied yet →
{ reason, tone: "soft" }, a quiet polite line, and the rail is left alone.
A toast is for neither of those. It leaves before a slow reader reaches it
and it is not attached to the button, which is what WCAG 3.3.1 is about. The one
failure a toast belongs to is the check that could not run at all — a network
drop, a 500 — and that arrives as onTransportError for you to render.
Reachability, deep links and resume
Mark a step satisfied when its data is already there. That single predicate
decides four things, so they can never disagree:
- which steps a pasted
#step=…link may open; - whether the rail starts complete or empty;
- whether jumping between steps is offered at all;
- which layout a phone gets.
Creating a record satisfies nothing, so a bookmark to step 4 rewinds to step 1.
Editing one satisfies everything, so the same link opens exactly where it points.
There is no mode="create" | "edit" to keep in sync with the data.
urlSync writes #step=<id> — a hash, not a query parameter, because a registry
component may not import a framework's router and a hash needs no adapter passed
in, never reaches the server, and makes the browser's Back button move a step
instead of leaving the page.
persistKey remembers the position and the steps passed for the life of the tab.
It is opt-in and off by default: wizard answers are frequently personal, and a
design system does not get to decide that for you. Never point it at payment
details.
Full-page flow
A create or edit flow that deserves its own page puts the step in the route:
/products/new/details, /products/new/pricing, /products/new/review. The page owns
step (read from the route segment) and passes onStepChange(id, { replace }) to its router —
replace is true when the form corrects the step itself (clamping a requested step it won't
admit to the furthest reachable one) and false for Next, Back and a jump from the rail, so the
browser's Back retraces what the person did.
Hash urlSync is the alternative for a host without routes; don't use both.
AppShellPage›PageHeaderwithbackRender(the list the flow came from) › the form in agrid md:grid-cols-[14rem_1fr]:MultiStepFormNav orientation="vertical"in the narrow column, the steps in the wide one.navigable="auto"lets the person jump to a step they have already reached, anddirtymakes Exit ask before discarding.MultiStepFormActions sticky="narrow"keeps Exit, Save draft, Back and Next in reach on a phone, and leaves them in the flow on a wide screen.- The Review step shows one
PropertyListper step, each with a Change link to that step's route, and a checklist of what's missing — aCheckorXin-textink plus the words "done" or "missing", never colour alone.
Inside a dialog, and leaving one
layout="panel" is the shape for a Dialog, Sheet or Drawer: the nav and the action
row hold their place, the step body becomes the one scrolling region, and the frame cannot
grow past the viewport and carry its own footer off screen.
On a page, a long step can push the action row below the fold instead. sticky on
MultiStepFormActions keeps the refusal and the row at the bottom of the scroll area while the
step scrolls under them; sticky="narrow" does it only on a phone-width form, where the row
leaves the screen first.
Pair it with dirty and MultiStepFormExit. You decide what dirty means; while it is true,
leaving asks first and the browser warns on a refresh or a closed tab — the half no
component can fake. With nothing dirty it just leaves, because a confirmation nobody needs
is the fastest way to teach people to dismiss confirmations unread.
When to drop to Stepper
When the chrome cannot look like ours — a sticky Continue bar pinned to the
bottom of a phone, a full-page onboarding where each step is its own illustrated
screen. Compose Stepper and hold the current step yourself; it still gives you
the states, the rail, the collapse and the focus move.
Three things stay true whichever you pick:
- States are explicit. Deriving them from an index bakes in "linear and always forward", which an import with a failed step is not.
- Focus moves once per transition.
Stepperowns it — do not also focus the first field. - Never advance optimistically. A flow that walks backwards after the fact loses the user's place and their trust.