---
url: https://docs.tailor.tech/app-shell/patterns/form-wizard.md
description: Multi-stage create flow with 3-7 steps and per-step validation gates
---

# 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

```tsx
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 `draft` state object above the `Form`; each step's fields read
  their initial value from it via `defaultValue`

## 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 — use `type="submit"` and let
  `onFormSubmit` gate it
* Wrapping all steps in one `<Form>` and hiding inactive ones — hidden required fields block submit
* Hand-building a CSV import wizard — use `CsvImporter`
