Multi-step wizards break complex forms into manageable steps. Here is how to build one with proper validation.
Step Configuration
// types.ts
import { z } from "zod";
export interface StepConfig {
id: string;
title: string;
description?: string;
schema: z.ZodSchema;
}
export const personalInfoSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
});
export const addressSchema = z.object({
street: z.string().min(1, "Street is required"),
city: z.string().min(1, "City is required"),
state: z.string().min(1, "State is required"),
zip: z.string().regex(/^\d{5}(-\d{4})?$/, "Invalid ZIP code"),
});
export const preferencesSchema = z.object({
plan: z.enum(["basic", "pro", "enterprise"]),
newsletter: z.boolean(),
terms: z.literal(true, {
errorMap: () => ({ message: "You must accept the terms" }),
}),
});
export const steps: StepConfig[] = [
{
id: "personal",
title: "Personal Info",
description: "Basic information about you",
schema: personalInfoSchema,
},
{
id: "address",
title: "Address",
description: "Your mailing address",
schema: addressSchema,
},
{
id: "preferences",
title: "Preferences",
description: "Choose your plan",
schema: preferencesSchema,
},
];
Stepper Hook
"use client";
import { useState, useCallback } from "react";
import type { StepConfig } from "./types";
import type { z } from "zod";
interface UseStepperOptions {
steps: StepConfig[];
onComplete: (data: Record<string, unknown>) => void;
}
export function useStepper({ steps, onComplete }: UseStepperOptions) {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
const currentConfig = steps[currentStep];
const isFirst = currentStep === 0;
const isLast = currentStep === steps.length - 1;
const validate = useCallback(
(data: Record<string, unknown>): boolean => {
const result = currentConfig.schema.safeParse(data);
if (result.success) {
setErrors({});
return true;
}
const fieldErrors: Record<string, string> = {};
for (const issue of result.error.issues) {
const key = issue.path.join(".");
if (!fieldErrors[key]) {
fieldErrors[key] = issue.message;
}
}
setErrors(fieldErrors);
return false;
},
[currentConfig],
);
const next = useCallback(
(stepData: Record<string, unknown>) => {
if (!validate(stepData)) return false;
const merged = { ...formData, ...stepData };
setFormData(merged);
setCompletedSteps((prev) => new Set([...prev, currentStep]));
if (isLast) {
onComplete(merged);
return true;
}
setCurrentStep((s) => s + 1);
setErrors({});
return true;
},
[currentStep, formData, isLast, onComplete, validate],
);
const back = useCallback(() => {
if (isFirst) return;
setCurrentStep((s) => s - 1);
setErrors({});
}, [isFirst]);
const goTo = useCallback(
(step: number) => {
// Only allow going to completed steps or the next incomplete step
if (step <= currentStep || completedSteps.has(step - 1)) {
setCurrentStep(step);
setErrors({});
}
},
[currentStep, completedSteps],
);
return {
currentStep,
currentConfig,
formData,
errors,
completedSteps,
isFirst,
isLast,
next,
back,
goTo,
totalSteps: steps.length,
};
}
Progress Indicator
interface ProgressProps {
steps: StepConfig[];
currentStep: number;
completedSteps: Set<number>;
onStepClick: (step: number) => void;
}
export function StepProgress({
steps,
currentStep,
completedSteps,
onStepClick,
}: ProgressProps) {
return (
<nav aria-label="Progress">
<ol className="flex items-center">
{steps.map((step, index) => {
const isCompleted = completedSteps.has(index);
const isCurrent = currentStep === index;
const isClickable = isCompleted || index <= currentStep;
return (
<li
key={step.id}
className={`flex items-center ${index < steps.length - 1 ? "flex-1" : ""}`}
>
<button
onClick={() => isClickable && onStepClick(index)}
disabled={!isClickable}
className={`flex items-center gap-2 ${isClickable ? "cursor-pointer" : "cursor-not-allowed"}`}
aria-current={isCurrent ? "step" : undefined}
>
<span
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
isCompleted
? "bg-green-500 text-white"
: isCurrent
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{isCompleted ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path
d="M2.5 7L5.5 10L11.5 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
index + 1
)}
</span>
<span
className={`text-sm hidden sm:block ${
isCurrent ? "font-medium" : "text-muted-foreground"
}`}
>
{step.title}
</span>
</button>
{index < steps.length - 1 && (
<div
className={`flex-1 h-0.5 mx-4 ${
completedSteps.has(index) ? "bg-green-500" : "bg-muted"
}`}
/>
)}
</li>
);
})}
</ol>
</nav>
);
}
Step Forms
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import type { StepConfig } from "./types";
interface StepFormProps {
config: StepConfig;
defaultValues: Record<string, unknown>;
errors: Record<string, string>;
onSubmit: (data: Record<string, unknown>) => boolean;
onBack?: () => void;
isFirst: boolean;
isLast: boolean;
}
export function StepForm({
config,
defaultValues,
onSubmit,
onBack,
isFirst,
isLast,
}: StepFormProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(config.schema),
defaultValues,
});
return (
<form onSubmit={handleSubmit((data) => onSubmit(data))} className="space-y-4">
<div>
<h2 className="text-xl font-semibold">{config.title}</h2>
{config.description && (
<p className="text-sm text-muted-foreground mt-1">{config.description}</p>
)}
</div>
{/* Render fields based on step ID */}
{config.id === "personal" && (
<>
<Field label="First Name" error={errors.firstName?.message as string}>
<input {...register("firstName")} className="w-full border rounded-md px-3 py-2" />
</Field>
<Field label="Last Name" error={errors.lastName?.message as string}>
<input {...register("lastName")} className="w-full border rounded-md px-3 py-2" />
</Field>
<Field label="Email" error={errors.email?.message as string}>
<input type="email" {...register("email")} className="w-full border rounded-md px-3 py-2" />
</Field>
</>
)}
{config.id === "address" && (
<>
<Field label="Street" error={errors.street?.message as string}>
<input {...register("street")} className="w-full border rounded-md px-3 py-2" />
</Field>
<Field label="City" error={errors.city?.message as string}>
<input {...register("city")} className="w-full border rounded-md px-3 py-2" />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="State" error={errors.state?.message as string}>
<input {...register("state")} className="w-full border rounded-md px-3 py-2" />
</Field>
<Field label="ZIP Code" error={errors.zip?.message as string}>
<input {...register("zip")} className="w-full border rounded-md px-3 py-2" />
</Field>
</div>
</>
)}
{config.id === "preferences" && (
<>
<Field label="Plan" error={errors.plan?.message as string}>
<select {...register("plan")} className="w-full border rounded-md px-3 py-2">
<option value="">Select a plan</option>
<option value="basic">Basic</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
</Field>
<label className="flex items-center gap-2">
<input type="checkbox" {...register("newsletter")} />
<span className="text-sm">Subscribe to newsletter</span>
</label>
<Field label="" error={errors.terms?.message as string}>
<label className="flex items-center gap-2">
<input type="checkbox" {...register("terms")} />
<span className="text-sm">I accept the terms and conditions</span>
</label>
</Field>
</>
)}
<div className="flex justify-between pt-4">
{!isFirst ? (
<button
type="button"
onClick={onBack}
className="px-4 py-2 border rounded-md text-sm"
>
Back
</button>
) : (
<div />
)}
<button
type="submit"
className="px-6 py-2 bg-primary text-primary-foreground rounded-md text-sm"
>
{isLast ? "Submit" : "Continue"}
</button>
</div>
</form>
);
}
function Field({
label,
error,
children,
}: {
label: string;
error?: string;
children: React.ReactNode;
}) {
return (
<div>
{label && <label className="block text-sm font-medium mb-1">{label}</label>}
{children}
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
</div>
);
}
Need Complex Form Flows?
We build multi-step form experiences that convert. Contact us to improve your forms.