Skip to content
View as Markdown

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