Skip to content
Component installs need the registry setup
VegaStack Design

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 haveReach for
Progress the system drives and a person readsStepper alone
A process a person drives, with bodies and controlsMultiStepForm
One question at a time, with its own shortcut keysQuestionnaire
Free movement between peer viewsTabs — 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.

Mark a step satisfied when its data is already there. That single predicate decides four things, so they can never disagree:

  1. which steps a pasted #step=… link may open;
  2. whether the rail starts complete or empty;
  3. whether jumping between steps is offered at all;
  4. 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 › PageHeader with backRender (the list the flow came from) › the form in a grid 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, and dirty makes 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 PropertyList per step, each with a Change link to that step's route, and a checklist of what's missing — a Check or X in -text ink plus the words "done" or "missing", never colour alone.

New product

Route: /products/new/details · last navigation: none yet

  1. 1DetailsCurrent stepName and code
  2. 2PricingNot startedList price
  3. 3ReviewNot startedCheck and create

The code on the price list.

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:

  1. States are explicit. Deriving them from an index bakes in "linear and always forward", which an import with a failed step is not.
  2. Focus moves once per transition. Stepper owns it — do not also focus the first field.
  3. Never advance optimistically. A flow that walks backwards after the fact loses the user's place and their trust.

On this page