# Form Wizard

> A React multi-step form wizard with a stepper, sync or async validation per step, a summary step and a full-screen phone layout.

- Type: React component (Forms)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add form-wizard`
- Docs: https://wingo-ui.com/components/form-wizard

## Interaction

Fill in a step and press Continue (or Enter in any single-line field, Cmd+Enter / Ctrl+Enter in a textarea): the step's validators run, and on success the step is marked with a check and the next one slides in from the side you are heading, while the card's height follows on a spring. A failed step turns its marker into a red "!", shakes the Continue button and puts focus on the first invalid field; an async check shows a spinner on the button after a moment. Back never validates. Click a completed step in the stepper to jump back to it (with linear={false} any step), and on the summary step the Edit links do the same. Optional steps show Skip. On the last step the button submits and shows a spinner while onComplete runs, then a check. Tab order is the stepper, the fields, then Back, Skip and Continue; after a step change focus lands on the new step's heading and screen readers hear "Step 2 of 5: Details". Below 768px the stepper becomes a compact progress bar with the step title, and with fullScreenOnMobile the wizard fills the screen with a sticky header, a scrolling body and a sticky footer of 48px buttons above the safe area (in the flow, so it rides above the keyboard). Steps change only through the buttons: no swipe, so sliders and carousels inside a step keep their gestures. Under reduced motion steps crossfade and nothing shakes.

## Installation

```bash
npx wingo-ui@latest add form-wizard
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/form-wizard` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/form-wizard.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"
import { FormWizard, FormWizardSummary, useFormWizardValidation, type WizardStep } from "@/components/ui/form-wizard"
import { Input } from "@/components/ui/input"
import { CurrencyInput } from "@/components/ui/currency-input"

type Listing = { title: string; price: number | null }

function Details() {
  const { register, control, trigger, formState } = useFormContext<Listing>()
  useFormWizardValidation(() => trigger(["title", "price"]))
  return (
    <div className="flex flex-col gap-4">
      <Input
        label="Title"
        error={formState.errors.title?.message}
        {...register("title", { minLength: { value: 10, message: "The title needs at least 10 characters" } })}
      />
      <Controller
        name="price"
        control={control}
        rules={{ required: "Add a price" }}
        render={({ field, fieldState }) => (
          <CurrencyInput label="Price" currency="USD" locale="en-US" value={field.value} onValueChange={field.onChange} onBlur={field.onBlur} ref={field.ref} error={fieldState.error?.message} />
        )}
      />
    </div>
  )
}

function Review() {
  const { getValues } = useFormContext<Listing>()
  return <FormWizardSummary editLabel="Edit" sections={[{ step: "details", items: [{ label: "Title", value: getValues("title") }] }]} />
}

const steps: WizardStep[] = [
  { id: "details", title: "Details", content: <Details /> },
  { id: "review", title: "Review", content: <Review /> },
]

export function PostListing() {
  const form = useForm<Listing>({ defaultValues: { title: "", price: null } })
  return (
    <FormProvider {...form}>
      <FormWizard
        steps={steps}
        labels={{ submit: "Publish", step: (i, n) => `Step ${i} of ${n}` }}
        onComplete={() => fetch("/api/listings", { method: "POST", body: JSON.stringify(form.getValues()) })}
      />
    </FormProvider>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `steps?` | `WizardStep[]` |  | { id, title, description?, icon?, content, optional?, validate? }. validate returns a boolean or a promise; a throw or rejection counts as failed and is logged. Alternatively pass <FormWizardStep> children with the same props (content is their children). An empty list renders nothing. |
| `stepper?` | `"horizontal" \| "vertical" \| "progress" \| "dots" \| "none"` | `"horizontal"` | The header from 768px up: numbered markers with connectors, a side column with descriptions, a progress bar with the step title, dots for onboardings, or nothing. Below 768px every stepper except none becomes the compact progress header. |
| `linear?` | `boolean` | `true` | Only Continue moves forward; completed steps stay clickable. false makes every step clickable (going forward still validates the current step first). |
| `size?` | `"sm" \| "md"` | `"md"` | Markers 24 / 28px, step titles 13 / 14px, footer buttons sm / md. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The active marker, the connector and progress fills, completed markers' tint and the Continue button. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone everywhere. Text on the fill turns black or white by lightness. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Corner shape of the footer buttons, scaled per size like the button's. |
| `showStepHeading?` | `boolean` | `true` | The step title and description above the fields. The title hides on its own wherever the header already shows it (phones, the progress stepper); false hides both but keeps the title for screen readers and as the focus target. |
| `fullScreenOnMobile?` | `boolean` | `true` | Below 768px the wizard fills its container's height: a sticky blurred header, a body that scrolls on its own and a sticky footer with 48px buttons (Continue full width, Back as an icon) padded for the safe area. Give the container a height (h-dvh for a page). |
| `animate?` | `boolean` | `true` | Steps slide 32px in the direction you move, the body's height follows on a spring, fills grow and a failed Continue shakes. false swaps steps instantly. Reduced motion always crossfades. |
| `keepMounted?` | `boolean` | `false` | Keeps every step mounted (inactive ones hidden and inert), for forms that keep their state in the DOM. react-hook-form does not need it: it keeps the values of unmounted fields. |
| `value?` | `string` |  | The current step id, controlled. Pair it with onValueChange. An unknown id shows the first step; a removed active step moves to the nearest one. |
| `defaultValue?` | `string` | `""` | The starting step id; empty (or an unknown id) starts on the first step. In a linear wizard the steps before it count as completed. |
| `children?` | `ReactNode` |  | <FormWizardStep> elements instead of the steps array; other children are ignored. |
| `onValueChange?` | `(value: string) => void` |  | Called with the new step id on every step change. |
| `onComplete?` | `(event: FormEvent<HTMLFormElement>) => (void \| Promise<unknown>)` |  | The last step's submit, after its validation. A promise shows the button loading, then a check (labels.done); a rejection shakes it. After an await read the form from event.target. Without onComplete a form action (formProps.action) runs instead. |
| `footer?` | `ReactNode` |  | Replaces the default footer; build one with useFormWizard() (a type="submit" button acts as Continue). null hides the footer. |
| `labels?` | `Partial<{ back; next; submit; skip; done; step: (index, count) => string; optional; completed; error; progress }>` |  | Every visible and announced string, merged over the English defaults: back "Back", next "Continue", submit "Submit", skip "Skip", done "Done", step (i, n) => "Step i of n", optional "Optional", completed "Completed", error "Needs attention" (both read after a step's title), progress "Progress" (names the stepper). |
| `formProps?` | `ComponentProps<"form">` |  | Extra props for the root <form> (action, id, method). Other unknown props land on the form too. |
| `classNames?` | `Partial<Record<"header" \| "stepper" \| "stepItem" \| "stepMarker" \| "stepTitle" \| "stepDescription" \| "connector" \| "progress" \| "progressBar" \| "progressText" \| "main" \| "body" \| "panel" \| "panelTitle" \| "panelDescription" \| "footer" \| "back" \| "skip" \| "next", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root <form>. |

## Parts

### FormWizardStep (component)

Declares a step as a child of FormWizard instead of the steps array; its children are the content. Props: Omit<WizardStep, "content"> & { children?: ReactNode }.

### useFormWizard (hook)

The wizard's state and actions inside FormWizard, for custom footers and summaries. next() and goTo() resolve to false when validation blocks them. Signature: () => { steps, value, index, count, isFirst, isLast, completed, errors, skipped, validating, submitting, done, next(), back(), goTo(id), skip() }.

### useFormWizardValidation (hook)

Registers a validator for the step the calling component renders in; removed on unmount. Validators run in order after the step's own validate. With react-hook-form: useFormWizardValidation(() => form.trigger(["title", "price"])). Signature: (validate: () => boolean | Promise<boolean>) => void.

### FormWizardSummary (component)

A review step: one list group per section (title defaults to the step's) with an Edit link that goes back to that step. Props: { sections: { step: string; title?: ReactNode; items: { label: ReactNode; value: ReactNode }[] }[]; editLabel?: string; editable?: boolean }.

### WizardStep (type)

One step of steps: { id, title, description?, icon?, content, optional?, validate? }.

### FormWizardState (type)

What useFormWizard returns: the steps, current index, completed / error / skipped sets, flags, and next, back, goTo and skip.

## Examples

- **Vertical stepper**: A side column with descriptions once the wizard is 672px wide on a 768px+ screen; narrower, the compact progress header takes over.
- **Progress header on a compact card**: stepper="progress" and size="sm": a bar, "Step 2 of 3" and the step title.
- **Jump anywhere**: linear={false}: every step is clickable. A custom color and pill buttons.
- **Async validation**: A validator registered by the step checks the EIN for a second; Next shows a spinner meanwhile.
- **Dots for an onboarding**: stepper="dots" with brand orange and an optional step you can skip.
- **Custom footer**: useFormWizard() builds a footer with a Save draft button next to Next.

## See also

- [Steps](https://wingo-ui.com/components/steps) ([Markdown](https://wingo-ui.com/components/steps.md))
- [Checkout steps](https://wingo-ui.com/components/checkout-steps) ([Markdown](https://wingo-ui.com/components/checkout-steps.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Radio Group](https://wingo-ui.com/components/radio-group) ([Markdown](https://wingo-ui.com/components/radio-group.md))
- [File Upload](https://wingo-ui.com/components/file-upload) ([Markdown](https://wingo-ui.com/components/file-upload.md))

---

Canonical: https://wingo-ui.com/components/form-wizard
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
