Multi Step Forms

Multi-step forms are used to break down long forms into smaller, manageable sections. They improve user experience by guiding users through the form completion process step by step.

Features

  • Step-by-Step Progress: Visual indicators show users their progress through the form
  • Form Validation: Built-in validation using React Hook Form and Zod
  • Smooth Animations: Transitions between steps with Framer Motion
  • Responsive Design: Works on all screen sizes
  • Customizable: Easily modify steps, fields, and validation rules

Preview

Installation

npm install framer-motion react-hook-form zod @hookform/resolvers

Copy and paste the following code into your project.

components/ui/multi-step-form.tsx

multi-step-form.tsx
"use client";
 
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { CheckCircle2, ArrowRight, ArrowLeft } from "lucide-react";
 
// Define the form schema for each step
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"),
  email: z.string().email("Please enter a valid email address"),
});
 
const addressSchema = z.object({
  address: z.string().min(5, "Address must be at least 5 characters"),
  city: z.string().min(2, "City must be at least 2 characters"),
  zipCode: z.string().min(5, "Zip code must be at least 5 characters"),
});
 
const accountSchema = z.object({
  username: z.string().min(3, "Username must be at least 3 characters"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
    .regex(/[0-9]/, "Password must contain at least one number"),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords do not match",
  path: ["confirmPassword"],
});
 
// Combine all schemas for the final form data
const formSchema = z.object({
  ...personalInfoSchema.shape,
  ...addressSchema.shape,
  ...accountSchema._def.schema.shape,
});
 
type FormData = z.infer<typeof formSchema>;
 
interface MultiStepFormProps {
  className?: string;
  onSubmit?: (data: FormData) => void;
}
 
export default function MultiStepForm({ className, onSubmit }: MultiStepFormProps) {
  const [step, setStep] = useState(0);
  const [formData, setFormData] = useState<Partial<FormData>>({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [isComplete, setIsComplete] = useState(false);
 
  // Define the steps
  const steps = [
    {
      id: "personal",
      title: "Personal Information",
      description: "Tell us about yourself",
      schema: personalInfoSchema,
      fields: [
        { name: "firstName", label: "First Name", type: "text", placeholder: "John" },
        { name: "lastName", label: "Last Name", type: "text", placeholder: "Doe" },
        { name: "email", label: "Email", type: "email", placeholder: "john.doe@example.com" },
      ],
    },
    {
      id: "address",
      title: "Address Information",
      description: "Where do you live?",
      schema: addressSchema,
      fields: [
        { name: "address", label: "Address", type: "text", placeholder: "123 Main St" },
        { name: "city", label: "City", type: "text", placeholder: "New York" },
        { name: "zipCode", label: "Zip Code", type: "text", placeholder: "10001" },
      ],
    },
    {
      id: "account",
      title: "Account Setup",
      description: "Create your account",
      schema: accountSchema,
      fields: [
        { name: "username", label: "Username", type: "text", placeholder: "johndoe" },
        { name: "password", label: "Password", type: "password", placeholder: "••••••••" },
        { name: "confirmPassword", label: "Confirm Password", type: "password", placeholder: "••••••••" },
      ],
    },
  ];
 
  // Get the current step schema
  const currentStepSchema = steps[step].schema;
 
  // Setup form with the current step schema
  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm<any>({
    resolver: zodResolver(currentStepSchema as z.ZodType<any>),
    defaultValues: formData,
  });
 
  // Calculate progress percentage
  const progress = ((step + 1) / steps.length) * 100;
 
  // Handle next step
  const handleNextStep = (data: any) => {
    const updatedData = { ...formData, ...data };
    setFormData(updatedData);
 
    if (step < steps.length - 1) {
      setStep(step + 1);
      // Reset form with the updated data for the next step
      reset(updatedData);
    } else {
      // Final step submission
      setIsSubmitting(true);
      setTimeout(() => {
        if (onSubmit) {
          onSubmit(updatedData as FormData);
        }
        setIsComplete(true);
        setIsSubmitting(false);
      }, 1500);
    }
  };
 
  // Handle previous step
  const handlePrevStep = () => {
    if (step > 0) {
      setStep(step - 1);
    }
  };
 
  // Animation variants
  const variants = {
    hidden: { opacity: 0, x: 50 },
    visible: { opacity: 1, x: 0 },
    exit: { opacity: 0, x: -50 },
  };
 
  return (
    <div className={cn("w-full max-w-md mx-auto p-6 rounded-lg shadow-lg bg-card/40", className)}>
      {!isComplete ? (
        <>
          {/* Progress bar */}
          <div className="mb-8">
            <div className="flex justify-between mb-2">
              <span className="text-sm font-medium">Step {step + 1} of {steps.length}</span>
              <span className="text-sm font-medium">{Math.round(progress)}%</span>
            </div>
            <Progress value={progress} className="h-2" />
          </div>
 
          {/* Step indicators */}
          <div className="flex justify-between mb-8">
            {steps.map((s, i) => (
              <div key={s.id} className="flex flex-col items-center">
                <div
                  className={cn(
                    "w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold",
                    i < step
                      ? "bg-primary text-primary-foreground"
                      : i === step
                      ? "bg-primary text-primary-foreground ring-2 ring-primary/30"
                      : "bg-secondary text-secondary-foreground"
                  )}
                >
                  {i < step ? <CheckCircle2 className="h-4 w-4" /> : i + 1}
                </div>
                <span className="text-xs mt-1 hidden sm:block">{s.title}</span>
              </div>
            ))}
          </div>
 
          {/* Form */}
          <AnimatePresence mode="wait">
            <motion.div
              key={step}
              initial="hidden"
              animate="visible"
              exit="exit"
              variants={variants}
              transition={{ duration: 0.3 }}
            >
              <div className="mb-6">
                <h2 className="text-xl font-bold">{steps[step].title}</h2>
                <p className="text-sm text-muted-foreground">{steps[step].description}</p>
              </div>
 
              <form onSubmit={handleSubmit(handleNextStep)} className="space-y-4">
                {steps[step].fields.map((field) => (
                  <div key={field.name} className="space-y-2">
                    <Label htmlFor={field.name}>{field.label}</Label>
                    <Input
                      id={field.name}
                      type={field.type}
                      placeholder={field.placeholder}
                      {...register(field.name as any)}
                      className={cn(errors[field.name as string] && "border-destructive")}
                    />
                    {errors[field.name as string] && (
                      <p className="text-sm text-destructive">
                        {errors[field.name as string]?.message as string}
                      </p>
                    )}
                  </div>
                ))}
 
                <div className="flex justify-between pt-4">
                  <Button
                    type="button"
                    variant="outline"
                    onClick={handlePrevStep}
                    disabled={step === 0}
                    className={cn(step === 0 && "invisible")}
                  >
                    <ArrowLeft className="mr-2 h-4 w-4" /> Back
                  </Button>
                  <Button type="submit" disabled={isSubmitting}>
                    {step === steps.length - 1 ? (
                      isSubmitting ? "Submitting..." : "Submit"
                    ) : (
                      <>
                        Next <ArrowRight className="ml-2 h-4 w-4" />
                      </>
                    )}
                  </Button>
                </div>
              </form>
            </motion.div>
          </AnimatePresence>
        </>
      ) : (
        <motion.div
          initial={{ opacity: 0, scale: 0.9 }}
          animate={{ opacity: 1, scale: 1 }}
          transition={{ duration: 0.5 }}
          className="text-center py-10"
        >
          <div className="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mb-4">
            <CheckCircle2 className="h-8 w-8 text-primary" />
          </div>
          <h2 className="text-2xl font-bold mb-2">Form Submitted!</h2>
          <p className="text-muted-foreground mb-6">
            Thank you for completing the form. We&apos;ll be in touch soon.
          </p>
          <Button onClick={() => {
            setStep(0);
            setFormData({});
            setIsComplete(false);
            reset({});
          }}>
            Start Over
          </Button>
        </motion.div>
      )}
    </div>
  );
}

Usage

The multi-step form component can be customized to fit your specific needs. Here's how to use it:

<MultiStepForm
  className="max-w-md mx-auto"
  onSubmit={(data) => {
    // Handle form submission
    console.log("Form data:", data);
  }}
/>

Customization

You can customize the form steps, fields, and validation rules by modifying the component code. The form is built using React Hook Form and Zod for validation, making it easy to add or modify fields and validation rules.

Adding Custom Steps

To add or modify steps, update the steps array in the component:

const steps = [
  {
    id: "personal",
    title: "Personal Information",
    description: "Tell us about yourself",
    schema: personalInfoSchema,
    fields: [
      { name: "firstName", label: "First Name", type: "text", placeholder: "John" },
      { name: "lastName", label: "Last Name", type: "text", placeholder: "Doe" },
      { name: "email", label: "Email", type: "email", placeholder: "john.doe@example.com" },
    ],
  },
  // Add more steps here
];

Customizing Validation

The form uses Zod for validation. You can customize the validation rules by modifying the schema for each step:

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"),
  email: z.string().email("Please enter a valid email address"),
});

Accessibility

The multi-step form is built with accessibility in mind:

  • Proper form labels and ARIA attributes
  • Keyboard navigation support
  • Error messages for form validation
  • Focus management between steps

Props

PropTypeDefault
className
string
undefined
onSubmit
(data: FormData) => void
undefined

Last updated on

On this page