Wizard Form
When to Use
- Multi-stage Create with 3–7 steps
- Onboarding and request flows where users should focus on one step at a time
- Per-step validation gates progression
For CSV/spreadsheet import specifically, use the CsvImporter component — it already implements the upload → map → validate → confirm flow. Don't rebuild it here.
Page Implementation
function FormWizard() {
const onComplete = (finalDraft: Draft) => window.alert(`Creating ${finalDraft.title}`);
const onCancel = () => {};
const [step, setStep] = useState(0);
// Accumulated values. Each step's Form unmounts on navigation, so the draft
// is what makes Back non-destructive — fields re-read it via `defaultValue`.
const [draft, setDraft] = useState<Draft>(INITIAL);
const isLastStep = step === STEPS.length - 1;
/**
* Each step is its own `Form`, and "Next" is a `type="submit"` button.
* `onFormSubmit` fires only after that step's fields pass validation, so
* progression is gated natively — no manual per-step validity check, and
* no deferring every error to the final submit.
*/
const handleStepSubmit = (values: Record<string, unknown>) => {
const merged = { ...draft, ...(values as Partial<Draft>) };
setDraft(merged);
if (isLastStep) {
onComplete(merged);
return;
}
setStep(step + 1);
};
return (
<Layout>
<Layout.Header title="Create task" />
<Layout.Column>
<Card.Root>
<Card.Content>
<div className="flex items-center gap-2">
{STEPS.map((label, i) => (
<Badge
key={label}
variant={i === step ? "default" : i < step ? "success" : "neutral"}
>
{i + 1}. {label}
</Badge>
))}
</div>
</Card.Content>
</Card.Root>
<Form key={step} noValidate onFormSubmit={handleStepSubmit}>
<Card.Root>
<Card.Header title={STEPS[step]} />
<Card.Content>
{step === 0 && (
<Fieldset.Root className="space-y-4">
<Field.Root name="title">
<Field.Label>Title</Field.Label>
<Field.Control required defaultValue={draft.title} />
<Field.Error match="valueMissing">Title is required.</Field.Error>
</Field.Root>
<Field.Root name="description">
<Field.Label>Description</Field.Label>
<Field.Control defaultValue={draft.description} />
</Field.Root>
</Fieldset.Root>
)}
{step === 1 && (
<Field.Root name="owner">
<Field.Label>Owner</Field.Label>
{/* Uncontrolled like every other field — `defaultValue`
restores the prior choice when the user steps Back. */}
<Select
items={OWNERS}
defaultValue={draft.owner || null}
placeholder="Assign owner"
/>
</Field.Root>
)}
{step === 2 && (
<Fieldset.Root className="grid gap-4 md:grid-cols-2">
<Field.Root name="startDate">
<Field.Label>Start date</Field.Label>
<Field.Control type="date" required defaultValue={draft.startDate} />
<Field.Error match="valueMissing">Start date is required.</Field.Error>
</Field.Root>
<Field.Root name="estimate">
<Field.Label>Estimate (hours)</Field.Label>
<Field.Control type="number" min={0} defaultValue={draft.estimate} />
</Field.Root>
</Fieldset.Root>
)}
{step === 3 && (
<dl className="grid grid-cols-2 gap-2 text-sm">
<dt className="text-muted-foreground">Title</dt>
<dd>{draft.title}</dd>
<dt className="text-muted-foreground">Owner</dt>
<dd>{draft.owner || "—"}</dd>
<dt className="text-muted-foreground">Start date</dt>
<dd>{draft.startDate || "—"}</dd>
<dt className="text-muted-foreground">Estimate</dt>
<dd>{draft.estimate ? `${draft.estimate}h` : "—"}</dd>
</dl>
)}
</Card.Content>
</Card.Root>
<div className="flex justify-between pt-4">
<Button
type="button"
variant="ghost"
onClick={() => (step === 0 ? onCancel() : setStep(step - 1))}
>
{step === 0 ? "Cancel" : "Back"}
</Button>
<Button type="submit">{isLastStep ? "Create" : "Next"}</Button>
</div>
</Form>
</Layout.Column>
</Layout>
);
}Constraints
- Max 7 steps — more than that causes user abandonment
- Back-navigation must preserve prior step's input
- Validation must be per-step — don't defer until final submit
- Step indicator collapses to "Step 2 of 4" label below 1024px
- One
<Form>rendered per step, keyed by step index so it remounts cleanly - Accumulated values live in a
draftstate object above theForm; each step's fields read their initial value from it viadefaultValue
Form state
Rules that apply to every form/* pattern are in components.md → Forms, with a worked example in form/modal → Form state: Form + Field is the default stack, submit via onFormSubmit, dropdowns need only a wrapping Field.Root (no name, no state), server errors route through errors, and React Hook Form is an optional escape hatch.
The wizard's specific mechanic: make "Next" a type="submit" button. onFormSubmit fires only after the current step's fields pass validation, so progression is gated natively — no manual validity check, and no deferring errors to the final submit. The handler merges that step's values into draft and advances; on the last step it calls the completion callback instead.
Because each step's Form unmounts on navigation, values must be lifted into draft — that is what makes Back non-destructive. Every field, dropdowns included, is uncontrolled and re-reads its prior value from draft via defaultValue; nothing needs an onChange.
Anti-patterns
- More than 7 steps — users lose context and abandon
- No back-navigation preservation — pressing Back loses prior step's input
- Validation deferred until final submit — failures force full re-traversal
- A plain
onClick"Next" that advances without validating — usetype="submit"and letonFormSubmitgate it - Wrapping all steps in one
<Form>and hiding inactive ones — hidden required fields block submit - Hand-building a CSV import wizard — use
CsvImporter