Form Wizard
A multi-step form built on TanStack Form’s composition model. Values are nested per step, each step is a withForm component that validates its own slice through a FormGroup, and one form instance holds everything.
What it is built on
- One form instance.
useFormWizardcreates a singleuseAppFormfrom yourformOptions, wiresrevalidateLogic()and a full-formonDynamicschema for the final submit, and owns step navigation, conditional visibility, and optional persistence. - Nested values per step.
defaultValuesis grouped by step ({ account: {...}, plan: {...} }), so each step owns a slice of the form. - Per-step schemas via
FormGroup. Each step is awithFormcomponent whoseform.FormGroupvalidates only that step’s slice with its own schema on advance. The fullschemapassed touseFormWizardis not sliced per step; it is only the final-submit backstop. - A composable Stepper renders the progress indicator from the visible steps.
Installation
npx shadcn@latest add @uipath/form-wizardHow it works
useFormWizardreturns theform, the visiblesteps, thecurrentStepId, and navigation helpers (next,back,goToStep,reset).- Each step is authored with
withFormand wraps aform.FormGroup. ItsonGroupSubmit(fired only when the group’s schema passes) callsnext(); on the last stepnext()runs the fullform.handleSubmit(). FormWizardprovides the wizard through context.FormWizardSteprenders its child only when it is the active step.FormWizardStepsrenders the Stepper, andFormWizardNavrenders Back plus a submit button that advances the current group.
The demo persists to localStorage, so refreshing the page restores the answers and the current step. Choosing the Enterprise plan reveals a conditional Billing step.
Usage
Define nested formOptions and a schema per step, then author each step with withForm:
import { formOptions } from '@tanstack/react-form'
import { z } from 'zod'
import { FieldGroup } from '@/components/ui/field'
import { withForm } from '@/components/ui/form'
import {
FormWizard,
FormWizardNav,
FormWizardStep,
FormWizardSteps,
useFormWizard,
useFormWizardContext,
type WizardStepDef,
} from '@/components/ui/form-wizard'
const accountSchema = z.object({ email: z.email('Enter a valid email.') })
const planSchema = z.object({ tier: z.string().min(1) })
const wizardSchema = z.object({ account: accountSchema, plan: planSchema })
type Values = z.infer<typeof wizardSchema>
const wizardOpts = formOptions({
defaultValues: { account: { email: '' }, plan: { tier: 'free' } },
})
const steps: WizardStepDef<Values>[] = [
{ id: 'account', title: 'Account' },
{ id: 'plan', title: 'Plan', condition: (v) => v.account.email !== '' },
]
const AccountStep = withForm({
...wizardOpts,
render: function Render({ form }) {
const { next } = useFormWizardContext()
return (
<form.FormGroup
name="account"
validators={{ onDynamic: accountSchema }}
onGroupSubmit={() => next()}
>
{(group) => (
<form
onSubmit={(e) => {
e.preventDefault()
void group.handleSubmit()
}}
>
<FieldGroup>
<form.AppField name="account.email">
{(field) => <field.TextField type="email" label="Email" />}
</form.AppField>
<FormWizardNav />
</FieldGroup>
</form>
)}
</form.FormGroup>
)
},
})
function SignUpWizard() {
const wizard = useFormWizard<Values>({
formOptions: wizardOpts,
schema: wizardSchema,
steps,
persist: { key: 'sign-up' },
onSubmit: (values) => console.log(values),
})
return (
<FormWizard wizard={wizard}>
<FormWizardSteps />
<FormWizardStep stepId="account">
<AccountStep form={wizard.form} />
</FormWizardStep>
{/* ...remaining steps... */}
</FormWizard>
)
}Step definition
| Field | Type | Notes |
|---|---|---|
id | string | Stable identifier used for navigation and persistence. |
title | ReactNode | Shown in the Stepper. |
description | ReactNode | Optional secondary line in the Stepper. |
condition | (values) => boolean | Optional. When it returns false the step is hidden from navigation and the Stepper. |
optional | boolean | Reserved for steps that should not block advancing. |
Each step’s fields and validation live in its withForm component, not in the step definition.
Conditional steps
A step with a condition is only shown when the predicate passes against the current (nested) values. Hidden steps are skipped by next/back and dropped from the Stepper.
Persistence
Pass persist: { key } to save the values and current step to localStorage, backed by useLocalStorage from @mantine/hooks. Every change is written immediately so a reload resumes where you left off, the saved state seeds defaultValues on load, and a successful submit or wizard.reset() clears the entry.
Validation
Advancing runs the current step’s FormGroup schema against its slice; errors render inline on that step’s fields. The final next() calls form.handleSubmit(), which validates the full onDynamic schema as a backstop.
Customizing the design
The chrome is composable at three levels, from smallest change to full control.
Restyle in place
Every part carries a data-slot attribute and merges an incoming className, so most theming needs only Tailwind classes. Because the registry copies the source into your app, you also own it outright.
Customize the Stepper and nav
FormWizardSteps accepts a render prop that keeps the step and state wiring while letting you own each node’s markup:
<FormWizardSteps>
{({ step, index, state, goTo }) => (
<button type="button" onClick={goTo} data-state={state}>
{index + 1}. {step.title}
</button>
)}
</FormWizardSteps>FormWizardNav composes FormWizardBackButton and FormWizardNextButton; use those parts directly to reorder, relabel, or add actions. FormWizardNextButton stays a submit button, so it advances the enclosing step’s FormGroup.
Bring your own chrome
For full control, read everything from useFormWizardContext() (stepIndex, stepCount, currentStep, isFirst, isLast, progress, next, back, goToStep, reset, form) and render your own header and buttons. The step logic, validation, and persistence stay intact. The demo below uses a custom progress bar and custom buttons with no Stepper.