Claude
Skills
Sign in
Back

form-wizard-builder

Included with Lifetime
$97 forever

Builds multi-step forms with validation schemas (Zod/Yup), step components, shared state management, progress indicators, review steps, and error handling. Use when creating "multi-step forms", "wizard flows", "onboarding forms", or "checkout processes".

Code Review

What this skill does


# Form Wizard Builder

Create multi-step form experiences with validation, state persistence, and review steps.

## Core Workflow

1. **Define steps**: Break form into logical sections
2. **Create schema**: Zod/Yup validation for each step
3. **Build step components**: Individual form sections
4. **State management**: Shared state across steps (Zustand/Context)
5. **Navigation**: Next/Back/Skip logic
6. **Progress indicator**: Visual step tracker
7. **Review step**: Summary before submission
8. **Error handling**: Per-step and final validation

## Basic Wizard Structure

```typescript
// 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)

```typescript
// 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)

```typescript
// 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

```typescript
// 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

```typescript
// 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"
                  )}
                />
              )}
      

Related in Code Review