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

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

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

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-row" | "multi-step-form-actions" | "multi-step-form-finish" | "multi-step-form-refusal"
MultiStepFormBack — data-slot="multi-step-form-back"
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 — data-slot="multi-step-form-next"
MultiStepFormSkip — data-slot="multi-step-form-skip"
MultiStepFormStep — data-slot="multi-step-form-step"

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

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.

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

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.
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'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.
persistKeystringRemembers 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.
stepstringThe 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*stringStable identifier. Also the value written to the address bar under urlSync.
label*stringShown in the rail and on the phone's section row.
backLabelstringOverrides the back action's label on this step alone.
beforeBackMultiStepFormGuardRuns before the flow leaves this step backwards — for a step that cannot simply be abandoned.
beforeNextMultiStepFormGuardRuns 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.
descriptionstringSecondary 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.
nextLabelstringOverrides 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.
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*stringOne sentence, shown beside the control it blocks and read out with it.
titlestringOptional 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"

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.
  • 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
Accessibilitykeyboard, labeled, status-announcement, focus-management
Visualdefault, loading, error, warning, skipped, disabled

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