Multi-Step Form
A guarded, branching flow around a Stepper — conditional steps, async guards, locking, deep links and resume, with no opinion about your fields.
- Status
- Since
0.12.0- Accessibility pattern
- one focus move per transition
Last updated
We’ll send a confirmation here.
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Multi-Step Form before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify multi-step-formInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/multi-step-formRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/alert, @vegastack/alert-dialog, @vegastack/button, @vegastack/stepper, @vegastack/use-mobile, @vegastack/tooltip.
Usage
Opt-in URL synchronization pushes intentional step changes and replaces only corrections. Persisted flows retain sealed steps by stable IDs, including across remounts.
A MultiStepFormStep mounts the first time its step is current and then stays mounted, hidden, while another step is current: going back keeps typed answers and loaded data, and a move never re-runs the body's effects. Steps never visited are not mounted. The entrance animation plays only when the flow moves, never on the first paint.
import {
MultiStepForm,
MultiStepFormNav,
MultiStepFormStep,
MultiStepFormActions,
} from "@/components/ui/multi-step-form";
<MultiStepForm
steps={[
{ id: "account", label: "Account" },
{ id: "billing", label: "Billing" },
{ id: "review", label: "Review" },
]}
>
<MultiStepFormNav aria-label="Signup" />
<MultiStepFormStep id="account">…</MultiStepFormStep>
<MultiStepFormStep id="billing">…</MultiStepFormStep>
<MultiStepFormStep id="review">…</MultiStepFormStep>
<MultiStepFormActions />
</MultiStepForm>;The steps array declares the flow — labels, branches, guards. Each
MultiStepFormStep declares one step's body, and only the current one
renders. The body is whatever you put in it: a
Field form, a review table, an upload surface, a
chart, nothing.
MultiStepFormActions is as plain as an overlay footer: no divider, no background. The row has
one order everywhere — [‹] [Cancel] …… [status] [Save] [›]: a ‹ icon button (Back, secondary,
not shown on the first step) then Cancel (ghost) at the start; the status ("Saving…"/"Saved"), an
optional outline save and a › icon button (Next, primary) at the end. On the last step the finish
action takes the primary slot with its text label ("Create", "Save product", "Done"). See
Footer.
Scope
It owns the flow and refuses to own your form, which is what keeps it at zero new dependencies.
| Behaviour | Where it lives |
|---|---|
| Sequencing, branching, guards, locking | This component |
| Progress display | Stepper, composed by MultiStepFormNav |
| Fields, schemas, validation | Yours — the guard is a function, so any validator plugs in |
| The answers themselves | Yours — when and satisfied are booleans you compute |
| "The check couldn't run" (network, 500) | Yours, via onTransportError — normally a toast |
| What "unsaved" means, and what leaving does | Yours, via dirty and onExit |
Adding React Hook Form or Zod here would each be a new sanctioned dependency exception. Because a guard is just a function returning a promise, neither is needed.
Anatomy
Examples
Branching
A step whose when is false leaves the rail, the count and the sequence — so
"step 3 of 4" stays true when a branch drops one. Switch the account type below
and watch a step appear and disappear.
We’ll send a confirmation here.
Guards that run, and fail
beforeNext returns true to pass or a sentence to refuse, and may be async —
the step goes loading, the action shows a spinner, and the flow never
advances optimistically. A refusal that came from a check that ran and failed
is an assertive Alert directly above the action row,
tied to the button through aria-describedby. This step also carries lock, so
passing it seals everything behind it.
End the number in 0002 to watch the check fail.
A gate that isn't an error
A step that simply is not finished yet is tone: "soft" — a quiet polite line,
and the rail is left alone, because "not yet" is not "broken". Press Continue
with the field empty.
Footer
The footer is the same everywhere. ‹ and › are icon buttons named "Back" and "Next" (their
accessible name and tooltip come from backLabel and nextLabel), with the same 1px press nudge
as every button. Back is not rendered on the first step. start takes Cancel
(MultiStepFormExit), status takes the save state, and secondary takes the optional outline
save, MultiStepFormSave — "Save draft" in a create flow, "Save" in an edit flow that saves
explicitly, "Done" in a flow that saves as you go. On the last step the finish action takes the
primary slot with submitLabel, and a MultiStepFormSave whose text equals that label steps aside
(hideOnLast overrides it) — a footer never shows two saves.
| State | Row |
|---|---|
| Create, first step | [Cancel] …… [›] |
| Create, middle step | [‹] [Cancel] …… [›] |
| Create, last step | [‹] [Cancel] …… [Create] |
| Create with a draft, last | [‹] [Cancel] …… [Save draft] [Save product] |
| Edit, explicit save, middle | [‹] [Cancel] …… [Save] [›] |
| Edit, explicit save, last | [‹] [Cancel] …… [Save] |
| Edit, auto-save, middle | [‹] …… ✓ Saved [Done] [›] |
| Edit, auto-save, last | [‹] …… ✓ Saved [Done] |
Create · first step
Create · middle step
Create · last step
Create with Save draft · middle step
Create with Save draft · last step
Edit, explicit save · middle step
Edit, explicit save · last step (one Save)
Edit, auto-save · middle step
Edit, auto-save · last step
<MultiStepForm steps={steps} submitLabel="Save product">
…
<MultiStepFormActions
start={<MultiStepFormExit />}
status={<AutoSaveIndicator status={status} />}
secondary={
<MultiStepFormSave onClick={saveDraft}>Save draft</MultiStepFormSave>
}
/>
</MultiStepForm>Editing an existing record
Mark a step satisfied when its data is already there. That one predicate
decides four things at once: the rail starts complete, jumping is offered, a
deep link opens exactly where it points instead of rewinding, and a phone gets
the tappable section list instead of a progress line. There is no
mode="create" | "edit" to keep in sync.
The pricing fields for this product.
Optional steps
An optional step carries the affix and offers Skip, which advances without
running beforeNext and records the step as skipped rather than complete.
Workspace — press Skip to pass this one over.
Sticky actions
On a long step, pass sticky to MultiStepFormActions so the refusal and the action row stay at
the bottom of the scroll area while the step scrolls under them — sticky="narrow" pins only
while the form is narrower than its @md container rung, a phone. The pinned row keeps the same
gap above its buttons as below them, in the surface it sits on, so content passing under it stops
short of the buttons. The row reports data-stuck while it is actually pinned over content and
then shows a hairline on its top edge, which takes no space; resting at the end of the step it
shows none and sits where an unpinned row would. The focus order does not change.
Details — paragraph 1 of a long step.
Details — paragraph 2 of a long step.
Details — paragraph 3 of a long step.
Details — paragraph 4 of a long step.
Details — paragraph 5 of a long step.
Details — paragraph 6 of a long step.
Details — paragraph 7 of a long step.
Details — paragraph 8 of a long step.
Details — paragraph 9 of a long step.
Details — paragraph 10 of a long step.
Details — paragraph 11 of a long step.
Details — paragraph 12 of a long step.
Inside a dialog
layout="panel" is the shape a wizard takes in a Dialog, Sheet or Drawer: the nav
and the action row hold their place while the step body becomes the one scrolling region,
so the frame cannot grow past the viewport and carry its own footer off screen. This
example also carries dirty — type into the first field, then press Cancel.
Steps across the top, checks that clear themselves
<MultiStepFormNav orientation="horizontal" /> puts the rail above the steps — the layout for a
full-page form, with no side column. A step's validate is a pure, synchronous check run before
beforeNext; its refusal re-reads the check on every render and clears itself (with the rail's
error mark) as soon as the answers pass. start puts Cancel (MultiStepFormExit, a ghost
button) right after Back at the start of the action row.
Saving as you go
For a record that already exists, save each change instead of asking for Save: useAutoSave
from auto-save-input sends the changed fields after a pause, and the action row's status and
secondary slots carry an AutoSaveIndicator and <MultiStepFormSave>Done</MultiStepFormSave>,
in that order before ›: [‹] …… ✓ Saved [Done] [›]. With submitLabel="Done" the last step shows
one primary Done.
mobileNav="stepper" keeps the rail on a phone, where it collapses to the step's label,
"Step n of N" and a progress bar, instead of the section list.
Saved 0 times.
Leaving with unsaved work
MultiStepFormExit leaves the flow, asking first when dirty says the current step holds
work that would be lost. With nothing dirty it simply calls onExit — a confirmation
nobody needs is the fastest way to teach people to dismiss confirmations unread. While
dirty is true the browser also warns on a refresh or a closed tab, which is the half no
component can fake.
<MultiStepForm steps={steps} dirty={form.isDirty} onExit={() => setOpen(false)}>
…
<MultiStepFormActions start={<MultiStepFormExit />} />
</MultiStepForm>Steps in the route
A host that keeps the step in its route (/products/new/pricing) owns it: pass step from the
route and navigate in onStepChange. The second argument says how: replace is false for Next,
Back and a jump from the rail — push a history entry — and true when the form corrected the step
itself, such as a pasted link to a step whose prerequisites are not met yet, which it clamps to the
furthest reachable step. Replacing keeps Back from returning to a step the form refused.
const router = useRouter();
<MultiStepForm
steps={steps}
step={params.step}
onStepChange={(id, { replace }) =>
replace
? router.replace(`/products/new/${id}`)
: router.push(`/products/new/${id}`)
}
>
…
</MultiStepForm>;Use the controlled step alone for a route-driven form; urlSync (the #step= hash) and
persistKey are for hosts that do not own the address.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
steps* | MultiStepFormStepSpec[] | — | Every step the flow can contain, including ones when currently hides. |
backLabel | string | 'Back' | Label for the backward action. |
defaultStep | string | the first visible step | The step to open on first render, when uncontrolled. A deep link, saved progress, or an unreachable value all resolve against reachability, so this is a request, not a command. |
dirty | boolean | false | Whether the current step holds work that would be lost. The component does not decide
what dirty means — you do. While it is true, MultiStepFormExit asks before leaving and
the browser warns on a refresh or a closed tab. |
exitCancelLabel | string | 'Keep editing' | Label of its dismissing action. |
exitConfirmLabel | string | 'Leave' | Label of its confirming action. |
exitDescription | string | "Your answers on this step haven't been saved yet." | Body of that confirmation. |
exitTitle | string | 'Leave without finishing?' | Heading of the confirmation raised when someone tries to leave dirty work. |
layout | "flow" | "panel" | 'flow' | panel is the shape a wizard takes inside a Dialog, Sheet or Drawer: the nav and
the action row hold their place while the step body becomes the one scrolling region.
flow lets the whole thing grow down the page. |
mobileNav | "sections" | "stepper" | 'sections' | What a phone shows for a flow whose steps can be jumped between. sections replaces the
rail with a tappable section list (and opens a fully satisfied record on it); stepper
keeps the Stepper, which collapses to the current step's label, "Step n of N" and a
progress bar, so the phone reads the same as a wide screen. A flow with nothing to jump
to always shows the stepper. |
navigable | "auto" | boolean | 'auto' | Whether reachable steps can be jumped to. auto offers it exactly when more than the
first step is reachable — which is what editing an existing record looks like, and what
a fresh create flow does not. |
nextLabel | string | 'Next' | Label for the forward action on every step but the last — the › button's accessible name and tooltip. |
onComplete | (() => void) | — | Fired when the last step's guard passes — the flow is finished. |
onExit | (() => void) | — | What leaving actually does — close the dialog, navigate away. Called once the user has confirmed, or straight away when nothing is dirty. |
onStepChange | ((id: string, details: MultiStepFormStepChangeDetails) => void) | — | Fired whenever the current step changes, however it changed. details.replace tells a
route-driven host whether to push a history entry or replace the current one: it is false
for Next, Back and a jump from the rail, and true when the form corrected the step itself
(a requested step it will not admit, clamped to the furthest reachable one) or followed the
address bar's hash. A one-argument handler keeps working. |
onTransportError | ((error: unknown, context: MultiStepFormGuardContext) => void) | — | Fired when a guard THREW rather than refused: the check could not run at all. This is the one failure a toast belongs to, and the host raises it. With no handler the flow falls back to an inline error, so a transport failure is never silent. |
persistKey | string | — | Remembers the current step and the steps passed, under this key, for the life of the TAB. Opt-in and off by default: wizard answers are frequently personal, and writing them to storage is not a default a design system gets to choose. Never use it for payment data. |
skipLabel | string | 'Skip' | Label for the skip action offered on an optional step. |
step | string | — | The current step, when the host wants to own it. Pair with onStepChange. |
submitLabel | string | 'Submit' | Label for the forward action on the last step. |
urlSync | boolean | false | Mirrors the current step to the address bar as #step=<id>, so the browser's Back
button moves a step instead of leaving the page. |
Data attributes and CSS variables on MultiStepForm
| Attribute | Values |
|---|---|
data-layout | mirrors a prop or state value |
data-pending | "" |
data-slot | "multi-step-form" | "multi-step-form-exit-confirm" | "multi-step-form-exit-prompt" |
data-step | mirrors a prop or state value |
Step
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Stable identifier. Also the value written to the address bar under urlSync. |
label* | string | — | Shown in the rail and on the phone's section row. |
backLabel | string | — | Overrides the back action's label on this step alone. |
beforeBack | MultiStepFormGuard | — | Runs before the flow leaves this step backwards — for a step that cannot simply be abandoned. |
beforeNext | MultiStepFormGuard | — | Runs before the flow leaves this step forwards. Return true to pass, or a sentence to
refuse. On the last step this is the submit check. |
canGoNext | boolean | true | A cheap synchronous gate: false disables the action with no round trip, for the case
where the answer is plainly incomplete. Use beforeNext when there is a reason worth
showing. |
description | string | — | Secondary line under the label. |
disabled | boolean | false | Never reachable, even when everything before it is satisfied — gated by a plan, say. |
lock | boolean | false | Once this step is passed, every step before it is sealed for good: Back is disabled and neither a jump nor a stale link can reopen them. For a step that commits something — a payment taken, a document signed. |
nextLabel | string | — | Overrides the forward action's label on this step alone. |
optional | boolean | false | Renders an "Optional" affix and offers a Skip control, which advances without running
beforeNext and marks the step skipped. |
satisfied | boolean | whether the step has been passed in this session | Whether the step is already complete independently of this session — because a record being edited already carries its data. It decides which steps are REACHABLE, which in turn decides deep links, whether jumping is offered, and which phone layout renders. |
validate | ((context: MultiStepFormGuardContext) => MultiStepFormGuardResult) | — | A synchronous, side-effect-free check of the step's answers, run before beforeNext.
Return true to pass or a refusal. Unlike a guard's refusal, one raised by validate
CLEARS ITSELF: while it shows, the check is re-read on every render, so the message and
the rail's error state go away the moment the answers become valid — the user never has
to press Next again just to find out they fixed it. Keep it cheap and pure; anything that
saves or calls a server belongs in beforeNext. |
warning | boolean | false | Marks the step as passable but carrying something the user should know. Renders the
rail's warning state. |
when | boolean | true | Whether the step exists in this flow at all. false removes it from the rail, the
count and the sequence, so "step 3 of 4" stays true when a branch drops one. The host
computes it from its own answers — this component holds no values of its own. |
Refusal
| Prop | Type | Default | Description |
|---|---|---|---|
reason* | string | — | One sentence, shown beside the control it blocks and read out with it. |
title | string | — | Optional heading for the error form. With none, the reason is the whole message. |
tone | "error" | "soft" | 'error' | soft is a gate not yet satisfied — a quiet polite line. error is a check that ran
and failed — an assertive Alert, and the step is marked in the rail. |
Nav
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | 'Progress' | Accessible name for the process. |
orientation | "auto" | "horizontal" | "vertical" | 'auto' | Passed through to Stepper. auto reads the step count. |
Data attributes and CSS variables on MultiStepFormNav
| Attribute | Values |
|---|---|
data-slot | "multi-step-form-heading" | "multi-step-form-nav" | "multi-step-form-overview-trigger" | "multi-step-form-section" | "multi-step-form-section-trigger" |
data-state | mirrors a prop or state value |
data-variant | "drill-in" | "section-list" | "stepper" |
Actions
| Prop | Type | Default | Description |
|---|---|---|---|
secondary | React.ReactNode | — | The optional outline save between the status and Next — <MultiStepFormSave>: "Save draft",
"Save" in an edit flow, or "Done" in a flow that saves as you go. It steps aside on the last
step when it repeats the finish label. Ignored when you pass children. |
start | React.ReactNode | — | Rendered at the start, right after Back — the place for leaving the flow
(<MultiStepFormExit />). Ignored when you pass children. |
status | React.ReactNode | — | A quiet status at the end of the row, just before the secondary action — the place for
an auto-save's "Saving…" / "Saved" (AutoSaveIndicator). Ignored when you pass children. |
sticky | "narrow" | boolean | false | Keep the refusal and the action row in view at the bottom of the scroll area while a long
step scrolls under them. "narrow" does so only while the form is narrower than its @md
container rung — a phone — and leaves the row in the flow on a wide screen. The row reports
data-stuck while it is actually pinned over content. |
Data attributes and CSS variables on MultiStepFormActions
| Attribute | Values |
|---|---|
data-slot | "multi-step-form-action-end" | "multi-step-form-action-row" | "multi-step-form-action-start" | "multi-step-form-actions" | "multi-step-form-finish" | "multi-step-form-refusal" |
data-sticky | mirrors a prop or state value |
data-stuck | "" |
data-tone | "error" | "soft" |
data-variant | "overview" |
Save
| Prop | Type | Default | Description |
|---|---|---|---|
hideOnLast | boolean | children === the finish label | Hide this button on the last step. By default it hides there exactly when its text equals
the finish label (submitLabel or the step's nextLabel) — the finish action already says
"Save", and a footer never shows two saves. |
loading | boolean | — | Shows a spinner over the label, blocks activation and sets aria-busy. The label keeps its
box at opacity: 0, so the button's width does not move and its accessible name survives
(API-5, A11Y-12). |
size | "default" | "icon" | "icon-lg" | "icon-sm" | "icon-xs" | "lg" | "sm" | "xs" | null | — | |
variant | "default" | "destructive" | "ghost" | "link" | "outline" | "secondary" | null | — |
Data attributes and CSS variables on MultiStepFormSave
| Attribute | Values |
|---|---|
data-slot | "multi-step-form-save" |
Accessibility
- Exactly one focus move per transition.
Steppermoves focus to the new step's label; this component deliberately does not also focus the first field, and mounts no live region for step changes — the focused label andaria-current="step"are the announcement. - A refusal that follows a failed check is
role="alert", which is assertive — correct here and only here, because it appears after mount in response to something the user did. A soft gate is a politerole="status". - The refusal is referenced by the forward action's
aria-describedby, so the reason reads out with the control it blocks (WCAG 3.3.1), and it persists rather than disappearing like a toast. - Sealed and unreachable steps are not focus targets; the phone section rows are
Buttons, so they clear the 24px touch floor. - ‹ and › are icon-only, so each carries its label as
aria-label("Back", "Next") and shows it in a tooltip on hover and focus; on the last step the finish action is a text button. - A hash naming a step the guards forbid is ignored and the bar corrected, so a pasted link cannot bypass a gate or leave the URL disagreeing with the screen.
| Key | Action |
|---|---|
| Tab | Reach the rail's jump targets, then the row. |
| Enter / Space | Activate the focused control. |
| Contract | States tested |
|---|---|
| Behaviour | default, pending, refused, sealed, skipped, branching, restored, complete, dirty, stuck |
| Accessibility | keyboard, labeled, status-announcement, focus-management |
| Visual | default, loading, error, warning, skipped, disabled, stuck |
Do / Don't
Stepper
A bounded linear process as an ordered list — seven step states on a numbered rail that fills in behind you, with focus that follows the flow.
Breadcrumb
The path to the current resource as a hierarchy of links — separators, the current page, a collapsed ellipsis, and any routing library's link through render.