Skip to content

SteppedForm component

SteppedForm is a linear multi-step wizard built on top of the existing Form/FormFieldSet system — it inherits every Form prop (onSubmit, validationMode, initialValues, and so on) and adds step orchestration on top: per-step field validation before advancing, four built-in header styles, and full support for async per-step checks.

Navigation14 propsAccessibility notes

Playground

Change a prop on the right and the preview updates. Switch to Code to copy what you see.

Recipes

Copy-paste patterns for the ways SteppedForm actually gets used — each one a real, self-contained example.

Multi-step wizard

Each step declares the fields it validates before advancing; every field registers with the form up front, so the final submit validates the whole schema, not just the last step.

<SteppedForm
  onSubmit={(data) => createAccount(data)}
  steps={[
    {
      key: "account",
      title: "Account",
      fields: ["email", "password"],
      content: (
        <>
          <FormFieldSet type="email" name="email" label="Email" validation={{ rules: { required: true, email: true } }} />
          <FormFieldSet type="password" name="password" label="Password" validation={{ rules: { required: true, minLength: 8 } }} />
        </>
      ),
    },
    {
      key: "profile",
      title: "Profile",
      fields: ["firstName", "lastName"],
      content: (
        <>
          <FormFieldSet type="text" name="firstName" label="First name" validation={{ rules: { required: true } }} />
          <FormFieldSet type="text" name="lastName" label="Last name" validation={{ rules: { required: true } }} />
        </>
      ),
    },
  ]}
/>

Header styles

headerVariant picks the step indicator — dots (default), progress (a filled bar), tabs (clickable labels), or none to render your own.

<SteppedForm headerVariant="progress" steps={steps} onSubmit={onSubmit} />

Defining steps

Each step needs a unique key, a title for the header, the field names it should validate before letting the user advance, and its actual content (typically a group of FormFieldSet fields). All fields across every step register with the form immediately, even on steps not yet visited — so the final submit's full-schema validation always sees the whole form, not just whichever step is currently showing.

<SteppedForm
  onSubmit={(data) => createAccount(data)}
  steps={[
    {
      key: "account",
      title: "Account",
      fields: ["email", "password"],
      content: (
        <>
          <FormFieldSet type="email" name="email" label="Email" validation={{ rules: { required: true, email: true } }} />
          <FormFieldSet type="password" name="password" label="Password" validation={{ rules: { required: true, minLength: 8 } }} />
        </>
      ),
    },
    {
      key: "profile",
      title: "Profile",
      fields: ["firstName", "lastName"],
      content: (
        <>
          <FormFieldSet type="text" name="firstName" label="First name" validation={{ rules: { required: true } }} />
          <FormFieldSet type="text" name="lastName" label="Last name" validation={{ rules: { required: true } }} />
        </>
      ),
    },
  ]}
/>

Header styles

headerVariant picks a built-in header — dots (compact, the default), progress (a filled progress bar), tabs (clickable step labels), or none if you're rendering your own step indicator elsewhere. disableHeaderNavigation stops the header itself from being a way to jump between steps, useful for a strictly linear flow.

<SteppedForm headerVariant="progress" steps={steps} onSubmit={onSubmit} />

Async per-step validation

Beyond field-level validation rules, a step's onValidate runs a sync or async check before the user is allowed past it — return true to advance, or a string (or false) to block, with the string becoming the step's error message. While it's pending, Next shows a loading state and both Back/Next disable to prevent a double-submit.

onValidate only gates advancing via Next, not the final Submit — Form's own full-schema validation already covers every field on submit. If a step's async check should also block the final submission, do that inside onSubmit itself.

{
  key: "account",
  title: "Account",
  fields: ["email"],
  content: <FormFieldSet type="email" name="email" label="Email" validation={{ rules: { required: true, email: true } }} />,
  onValidate: async (formData) => {
    const available = await checkEmailAvailable(formData.email as string);
    return available || "That email is already taken";
  },
}

Syncing the active step to the URL

SteppedForm stays router-agnostic on purpose — use the controlled stepIndex/onStepChange pair to sync the active step with whatever URL approach your app already uses (the History API directly, or your router's own navigation call).

const [stepIndex, setStepIndex] = useState(() => {
  const n = Number(new URLSearchParams(window.location.search).get("step"));
  return Number.isFinite(n) && n >= 0 ? n : 0;
});

useEffect(() => {
  const params = new URLSearchParams(window.location.search);
  params.set("step", String(stepIndex));
  window.history.replaceState(null, "", `?${params}`);
}, [stepIndex]);

<SteppedForm stepIndex={stepIndex} onStepChange={setStepIndex} steps={steps} onSubmit={onSubmit} />

Props

The full public API for SteppedForm. Every prop is stable and intentionally typed.

Content

steps

SteppedFormStep[]

default: —

Ordered step definitions: { key, title, description?, fields?, content, isOptional? } (required).

backLabel / nextLabel / submitLabel

ReactNode

default: "Back" / "Next" / "Submit"

Footer action button labels.

Appearance

headerVariant

"dots" | "progress" | "tabs" | "none"

default: "dots"

Built-in step-header style.

Behavior & states

initialStep

number

default: 0

Uncontrolled initial step index.

stepIndex

number

default: —

Controlled current step index.

disableHeaderNavigation

boolean

default: false

Prevents jumping steps by clicking the header.

renderHeader

(args) => ReactNode

default: —

Fully custom header renderer, replacing headerVariant.

renderStepHeaderItem

(args) => ReactNode

default: —

Custom renderer for a single step-header item.

validationMode / showErrorsOn / initialValues / ...

see Form props

default: see Form

All other Form props are inherited (steps replaces children).

Events

onStepChange

(nextIndex: number) => void

default: —

Called when the active step changes.

onSubmit

(formData: FormData) => void | Promise<void>

default: —

Inherited from Form — called on final-step submit (required).

Styling

classNames

Partial<Record<SteppedFormSlot, string>>

default: —

Per-slot class overrides (11 slots: base/header/steps/stepButton/stepDot/stepLabel/stepDescription/progress/progressTrack/progressIndicator/content/footer/actions).

className

string

default: —

Class applied to the root element.

style

CSSProperties

default: —

Inline styles for the root element.

Accessibility

  • The active step in the header carries aria-current="step".
  • Future, not-yet-reached steps are disabled in the default linear mode — not clickable until the user has actually reached them, which keeps keyboard/tab order matching the visual flow.
  • Back/Next/Submit render with correct native button types (button for Back/Next, submit for the final step) so Enter inside a field behaves the way a user expects.
  • A step's onValidate error message renders with role="alert", the same as ordinary field-level validation errors.

Best practices

  • Keep each step's fields array accurate — it's what gets validated before Next is allowed, so a field left out of it can silently be skipped past while still invalid.
  • Use headerVariant="tabs" only for flows where jumping between steps out of order genuinely makes sense; for a strictly sequential flow (checkout, onboarding), dots or progress read more honestly.
  • Reach for onValidate for checks that need a network round-trip (username/email availability); keep everything checkable synchronously in the field's own validation rules instead, since it runs without the extra async overhead.
  • Don't try to gate final submission through a step's onValidate — it only runs on Next. Put submission-blocking async checks inside onSubmit itself.