Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
Since
0.12.0
Accessibility pattern
one focus move per transition

Last updated

AccountStep 1 of 3
x

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-form

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/multi-step-form

Run 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.

BehaviourWhere it lives
Sequencing, branching, guards, lockingThis component
Progress displayStepper, composed by MultiStepFormNav
Fields, schemas, validationYours — the guard is a function, so any validator plugs in
The answers themselvesYours — 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 doesYours, 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

MultiStepForm — data-slot="multi-step-form" | "multi-step-form-exit-confirm" | "multi-step-form-exit-prompt"
MultiStepFormActions — 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"
MultiStepFormBack
MultiStepFormExit — data-slot="multi-step-form-exit"
MultiStepFormNav — data-slot="multi-step-form-heading" | "multi-step-form-nav" | "multi-step-form-overview-trigger" | "multi-step-form-section" | "multi-step-form-section-trigger"
MultiStepFormNext
MultiStepFormSkip — data-slot="multi-step-form-skip"
MultiStepFormStep — data-slot="multi-step-form-step"
MultiStepFormSave — data-slot="multi-step-form-save"

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.

AccountStep 1 of 3
x

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.

BillingStep 1 of 2
x

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.

Map columnsStep 1 of 2
x

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.

StateRow
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

Saved

Edit, auto-save · last step

Saved
<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.

PricingStep 2 of 4
x

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.

WorkspaceStep 1 of 3
x

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.

DetailsStep 1 of 2
x

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.

BasicsStep 1 of 4
x
Type

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.

DetailsStep 1 of 4
x

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

PropTypeDefaultDescription
steps*MultiStepFormStepSpec[]—Every step the flow can contain, including ones when currently hides.
backLabelstring'Back'Label for the backward action.
defaultStepstringthe first visible stepThe 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.
dirtybooleanfalseWhether 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.
exitCancelLabelstring'Keep editing'Label of its dismissing action.
exitConfirmLabelstring'Leave'Label of its confirming action.
exitDescriptionstring"Your answers on this step haven't been saved yet."Body of that confirmation.
exitTitlestring'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.
nextLabelstring'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.
persistKeystring—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.
skipLabelstring'Skip'Label for the skip action offered on an optional step.
stepstring—The current step, when the host wants to own it. Pair with onStepChange.
submitLabelstring'Submit'Label for the forward action on the last step.
urlSyncbooleanfalseMirrors 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

AttributeValues
data-layoutmirrors a prop or state value
data-pending""
data-slot"multi-step-form" | "multi-step-form-exit-confirm" | "multi-step-form-exit-prompt"
data-stepmirrors a prop or state value

Step

PropTypeDefaultDescription
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.
backLabelstring—Overrides the back action's label on this step alone.
beforeBackMultiStepFormGuard—Runs before the flow leaves this step backwards — for a step that cannot simply be abandoned.
beforeNextMultiStepFormGuard—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.
canGoNextbooleantrueA 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.
descriptionstring—Secondary line under the label.
disabledbooleanfalseNever reachable, even when everything before it is satisfied — gated by a plan, say.
lockbooleanfalseOnce 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.
nextLabelstring—Overrides the forward action's label on this step alone.
optionalbooleanfalseRenders an "Optional" affix and offers a Skip control, which advances without running beforeNext and marks the step skipped.
satisfiedbooleanwhether the step has been passed in this sessionWhether 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.
warningbooleanfalseMarks the step as passable but carrying something the user should know. Renders the rail's warning state.
whenbooleantrueWhether 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

PropTypeDefaultDescription
reason*string—One sentence, shown beside the control it blocks and read out with it.
titlestring—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.
PropTypeDefaultDescription
aria-labelstring'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

AttributeValues
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-statemirrors a prop or state value
data-variant"drill-in" | "section-list" | "stepper"

Actions

PropTypeDefaultDescription
secondaryReact.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.
startReact.ReactNode—Rendered at the start, right after Back — the place for leaving the flow (<MultiStepFormExit />). Ignored when you pass children.
statusReact.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" | booleanfalseKeep 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

AttributeValues
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-stickymirrors a prop or state value
data-stuck""
data-tone"error" | "soft"
data-variant"overview"

Save

PropTypeDefaultDescription
hideOnLastbooleanchildren === the finish labelHide 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.
loadingboolean—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

AttributeValues
data-slot"multi-step-form-save"

Accessibility

  • Exactly one focus move per transition. Stepper moves 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 and aria-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 polite role="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.
KeyAction
TabReach the rail's jump targets, then the row.
Enter / SpaceActivate the focused control.
ContractStates tested
Behaviourdefault, pending, refused, sealed, skipped, branching, restored, complete, dirty, stuck
Accessibilitykeyboard, labeled, status-announcement, focus-management
Visualdefault, loading, error, warning, skipped, disabled, stuck

Do / Don't

Do
Return a sentence from beforeNext and let the component place it — the reason belongs beside the button it blocks, and it has to still be there when the user comes back to try again.
Don't
Announce a refusal with a toast. It leaves before a slow reader gets to it and it is not tied to the control, which is exactly what WCAG 3.3.1 is about; a toast is for the check that could not run at all.

On this page