Form Wizard Builder
Create multi-step form experiences with validation, state persistence, and review steps.
Core Workflow
-
Define steps: Break form into logical sections
-
Create schema: Zod/Yup validation for each step
-
Build step components: Individual form sections
-
State management: Shared state across steps (Zustand/Context)
-
Navigation: Next/Back/Skip logic
-
Progress indicator: Visual step tracker
-
Review step: Summary before submission
-
Error handling: Per-step and final validation
Basic Wizard Structure
// types/wizard.ts export type WizardStep = { id: string; title: string; description?: string; component: React.ComponentType<StepProps>; schema: z.ZodSchema; isOptional?: boolean; };
export type WizardData = { personal: PersonalInfoData; contact: ContactData; preferences: PreferencesData; };
Validation Schemas (Zod)
// schemas/wizard.schema.ts import { z } from "zod";
export const personalInfoSchema = z.object({ firstName: z.string().min(2, "First name must be at least 2 characters"), lastName: z.string().min(2, "Last name must be at least 2 characters"), dateOfBirth: z.string().refine((date) => { const age = new Date().getFullYear() - new Date(date).getFullYear(); return age >= 18; }, "Must be at least 18 years old"), });
export const contactSchema = z.object({ email: z.string().email("Invalid email address"), phone: z.string().regex(/^+?[\d\s-()]+$/, "Invalid phone number"), address: z.object({ street: z.string().min(1, "Street is required"), city: z.string().min(1, "City is required"), zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, "Invalid ZIP code"), }), });
export const preferencesSchema = z.object({ notifications: z.object({ email: z.boolean(), sms: z.boolean(), push: z.boolean(), }), interests: z.array(z.string()).min(1, "Select at least one interest"), });
// Complete wizard schema export const wizardSchema = z.object({ personal: personalInfoSchema, contact: contactSchema, preferences: preferencesSchema, });
export type WizardFormData = z.infer<typeof wizardSchema>;
State Management (Zustand)
// stores/wizard.store.ts import { create } from "zustand"; import { persist } from "zustand/middleware";
interface WizardState { currentStep: number; data: Partial<WizardFormData>; completedSteps: number[]; isSubmitting: boolean;
setCurrentStep: (step: number) => void; updateStepData: (step: string, data: any) => void; markStepComplete: (step: number) => void; nextStep: () => void; prevStep: () => void; resetWizard: () => void; submitWizard: () => Promise<void>; }
export const useWizardStore = create<WizardState>()( persist( (set, get) => ({ currentStep: 0, data: {}, completedSteps: [], isSubmitting: false,
setCurrentStep: (step) => set({ currentStep: step }),
updateStepData: (step, newData) =>
set((state) => ({
data: {
...state.data,
[step]: { ...state.data[step], ...newData },
},
})),
markStepComplete: (step) =>
set((state) => ({
completedSteps: Array.from(new Set([...state.completedSteps, step])),
})),
nextStep: () =>
set((state) => ({
currentStep: Math.min(state.currentStep + 1, steps.length - 1),
})),
prevStep: () =>
set((state) => ({
currentStep: Math.max(state.currentStep - 1, 0),
})),
resetWizard: () =>
set({
currentStep: 0,
data: {},
completedSteps: [],
isSubmitting: false,
}),
submitWizard: async () => {
set({ isSubmitting: true });
try {
// Submit to API
await fetch("/api/wizard", {
method: "POST",
body: JSON.stringify(get().data),
});
get().resetWizard();
} catch (error) {
console.error("Submission failed:", error);
} finally {
set({ isSubmitting: false });
}
},
}),
{
name: "wizard-storage",
}
) );
Main Wizard Component
// components/Wizard.tsx "use client";
import { useState } from "react"; import { useWizardStore } from "@/stores/wizard.store"; import { ProgressIndicator } from "./ProgressIndicator"; import { PersonalInfoStep } from "./steps/PersonalInfoStep"; import { ContactStep } from "./steps/ContactStep"; import { PreferencesStep } from "./steps/PreferencesStep"; import { ReviewStep } from "./steps/ReviewStep";
const steps = [ { id: "personal", title: "Personal Information", component: PersonalInfoStep, schema: personalInfoSchema, }, { id: "contact", title: "Contact Details", component: ContactStep, schema: contactSchema, }, { id: "preferences", title: "Preferences", component: PreferencesStep, schema: preferencesSchema, isOptional: true, }, { id: "review", title: "Review", component: ReviewStep, schema: z.any(), }, ];
export function Wizard() { const { currentStep } = useWizardStore(); const CurrentStepComponent = steps[currentStep].component;
return ( <div className="mx-auto max-w-2xl space-y-8 p-6"> <ProgressIndicator steps={steps} currentStep={currentStep} />
<div className="rounded-lg border bg-white p-8 shadow-sm">
<div className="mb-6">
<h2 className="text-2xl font-bold">{steps[currentStep].title}</h2>
{steps[currentStep].description && (
<p className="text-gray-600">{steps[currentStep].description}</p>
)}
</div>
<CurrentStepComponent />
</div>
</div>
); }
Progress Indicator
// components/ProgressIndicator.tsx import { cn } from "@/lib/utils"; import { CheckIcon } from "@/components/icons";
interface ProgressIndicatorProps { steps: Array<{ id: string; title: string }>; currentStep: number; }
export function ProgressIndicator({ steps, currentStep, }: ProgressIndicatorProps) { return ( <nav aria-label="Progress"> <ol className="flex items-center justify-between"> {steps.map((step, index) => { const isComplete = index < currentStep; const isCurrent = index === currentStep;
return (
<li key={step.id} className="flex flex-1 items-center">
<div className="flex flex-col items-center">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-full border-2",
isComplete && "border-primary-500 bg-primary-500",
isCurrent && "border-primary-500 bg-white",
!isComplete && !isCurrent && "border-gray-300 bg-white"
)}
>
{isComplete ? (
<CheckIcon className="h-5 w-5 text-white" />
) : (
<span
className={cn(
"text-sm font-medium",
isCurrent ? "text-primary-500" : "text-gray-500"
)}
>
{index + 1}
</span>
)}
</div>
<span
className={cn(
"mt-2 text-sm font-medium",
isCurrent ? "text-primary-500" : "text-gray-500"
)}
>
{step.title}
</span>
</div>
{index < steps.length - 1 && (
<div
className={cn(
"mx-4 h-0.5 flex-1",
isComplete ? "bg-primary-500" : "bg-gray-300"
)}
/>
)}
</li>
);
})}
</ol>
</nav>
); }
Step Component Example
// components/steps/PersonalInfoStep.tsx "use client";
import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useWizardStore } from "@/stores/wizard.store"; import { personalInfoSchema } from "@/schemas/wizard.schema"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label";
export function PersonalInfoStep() { const { data, updateStepData, markStepComplete, nextStep } = useWizardStore();
const { register, handleSubmit, formState: { errors }, } = useForm({ resolver: zodResolver(personalInfoSchema), defaultValues: data.personal || {}, });
const onSubmit = (formData: any) => { updateStepData("personal", formData); markStepComplete(0); nextStep(); };
return ( <form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <div className="space-y-4"> <div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-2"> <Label htmlFor="firstName">First Name</Label> <Input id="firstName" {...register("firstName")} error={errors.firstName?.message} /> </div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
{...register("lastName")}
error={errors.lastName?.message}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="dateOfBirth">Date of Birth</Label>
<Input
id="dateOfBirth"
type="date"
{...register("dateOfBirth")}
error={errors.dateOfBirth?.message}
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit">Next Step</Button>
</div>
</form>
); }
Review Step
// components/steps/ReviewStep.tsx "use client";
import { useWizardStore } from "@/stores/wizard.store"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card";
export function ReviewStep() { const { data, isSubmitting, submitWizard, setCurrentStep } = useWizardStore();
return ( <div className="space-y-6"> <Card className="p-6"> <div className="mb-4 flex items-center justify-between"> <h3 className="text-lg font-semibold">Personal Information</h3> <Button variant="ghost" size="sm" onClick={() => setCurrentStep(0)}> Edit </Button> </div> <dl className="space-y-2"> <div className="flex justify-between"> <dt className="text-gray-600">Name:</dt> <dd className="font-medium"> {data.personal?.firstName} {data.personal?.lastName} </dd> </div> <div className="flex justify-between"> <dt className="text-gray-600">Date of Birth:</dt> <dd className="font-medium">{data.personal?.dateOfBirth}</dd> </div> </dl> </Card>
<Card className="p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Contact Details</h3>
<Button variant="ghost" size="sm" onClick={() => setCurrentStep(1)}>
Edit
</Button>
</div>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-gray-600">Email:</dt>
<dd className="font-medium">{data.contact?.email}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600">Phone:</dt>
<dd className="font-medium">{data.contact?.phone}</dd>
</div>
</dl>
</Card>
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => setCurrentStep((prev) => prev - 1)}
>
Back
</Button>
<Button onClick={submitWizard} isLoading={isSubmitting}>
Submit Application
</Button>
</div>
</div>
); }
Navigation Controls
// components/WizardNavigation.tsx interface WizardNavigationProps { onNext?: () => void; onPrev?: () => void; onSkip?: () => void; isFirstStep: boolean; isLastStep: boolean; isOptional?: boolean; nextLabel?: string; prevLabel?: string; }
export function WizardNavigation({ onNext, onPrev, onSkip, isFirstStep, isLastStep, isOptional, nextLabel = "Next", prevLabel = "Back", }: WizardNavigationProps) { return ( <div className="flex items-center justify-between"> <div> {!isFirstStep && ( <Button variant="outline" onClick={onPrev}> {prevLabel} </Button> )} </div>
<div className="flex gap-2">
{isOptional && (
<Button variant="ghost" onClick={onSkip}>
Skip
</Button>
)}
<Button onClick={onNext}>{isLastStep ? "Submit" : nextLabel}</Button>
</div>
</div>
); }
Persistence (LocalStorage)
// hooks/useWizardPersistence.ts import { useEffect } from "react"; import { useWizardStore } from "@/stores/wizard.store";
export function useWizardPersistence() { const { data, currentStep } = useWizardStore();
// Auto-save to localStorage useEffect(() => { localStorage.setItem("wizard-data", JSON.stringify(data)); localStorage.setItem("wizard-step", String(currentStep)); }, [data, currentStep]);
// Load on mount useEffect(() => { const savedData = localStorage.getItem("wizard-data"); const savedStep = localStorage.getItem("wizard-step");
if (savedData) {
// Restore state
}
}, []); }
Best Practices
-
Validate per step: Don't wait until end
-
Save progress: Persist to localStorage/server
-
Allow navigation: Let users go back and edit
-
Show progress: Clear visual indicator
-
Review before submit: Summary step is crucial
-
Handle errors gracefully: Show which step has errors
-
Mobile responsive: Stack progress on mobile
-
Accessibility: Keyboard navigation, ARIA labels
Output Checklist
-
Step definitions with schemas
-
Validation with Zod/Yup
-
State management (Zustand/Context)
-
Progress indicator component
-
Individual step components
-
Navigation controls (Next/Back/Skip)
-
Review/summary step
-
Error handling per step
-
Persistence mechanism
-
Mobile-responsive design