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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | SteppedFormStep[] | — | Ordered step definitions: { key, title, description?, fields?, content, isOptional? } (required). |
| backLabel / nextLabel / submitLabel | ReactNode | "Back" / "Next" / "Submit" | Footer action button labels. |
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
| Prop | Type | Default | Description |
|---|---|---|---|
| headerVariant | "dots" | "progress" | "tabs" | "none" | "dots" | Built-in step-header style. |
headerVariant
"dots" | "progress" | "tabs" | "none"
default: "dots"
Built-in step-header style.
Behavior & states
| Prop | Type | Default | Description |
|---|---|---|---|
| initialStep | number | 0 | Uncontrolled initial step index. |
| stepIndex | number | — | Controlled current step index. |
| disableHeaderNavigation | boolean | false | Prevents jumping steps by clicking the header. |
| renderHeader | (args) => ReactNode | — | Fully custom header renderer, replacing headerVariant. |
| renderStepHeaderItem | (args) => ReactNode | — | Custom renderer for a single step-header item. |
| validationMode / showErrorsOn / initialValues / ... | see Form props | see Form | All other Form props are inherited (steps replaces children). |
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
| Prop | Type | Default | Description |
|---|---|---|---|
| onStepChange | (nextIndex: number) => void | — | Called when the active step changes. |
| onSubmit | (formData: FormData) => void | Promise<void> | — | Inherited from Form — called on final-step submit (required). |
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
| Prop | Type | Default | Description |
|---|---|---|---|
| classNames | Partial<Record<SteppedFormSlot, string>> | — | Per-slot class overrides (11 slots: base/header/steps/stepButton/stepDot/stepLabel/stepDescription/progress/progressTrack/progressIndicator/content/footer/actions). |
| className | string | — | Class applied to the root element. |
| style | CSSProperties | — | Inline styles for the root element. |
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.