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
Add Multi-Step Form from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/multi-step-formThe same command installs the registry items it composes: @vegastack/alert, @vegastack/alert-dialog, @vegastack/button, @vegastack/stepper, @vegastack/use-mobile.
Usage
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.
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.
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.
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.
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>
<MultiStepFormExit>Cancel</MultiStepFormExit>
<div className="flex items-center gap-2">
<MultiStepFormBack />
<MultiStepFormNext />
</div>
</MultiStepFormActions>
</MultiStepForm>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. |
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 | 'Continue' | Label for the forward action on every step but the last. |
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) => void) | — | Fired whenever the current step changes, however it changed. |
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. |
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" |
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. - 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 |
| Accessibility | keyboard, labeled, status-announcement, focus-management |
| Visual | default, loading, error, warning, skipped, disabled |
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.