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
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
State Management (Zustand) // stores/wizard.store.ts import { create } from "zustand"; import { persist } from "zustand/middleware";
interface WizardState {
currentStep: number;
data: Partial
setCurrentStep: (step: number) => void;
updateStepData: (step: string, data: any) => void;
markStepComplete: (step: number) => void;
nextStep: () => void;
prevStep: () => void;
resetWizard: () => void;
submitWizard: () => Promise
export const useWizardStore = create
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="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 (
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 (