Multi Step Form

multi-step-form.tsx
"use client";

import React, { useState, useMemo } from "react";
import { format } from "date-fns";
import { Check, ChevronRight, ChevronLeft, CalendarIcon } from "lucide-react";
import { useForm, FormProvider as Form } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import {
  Field,
  FieldLabel,
  FieldDescription,
  FieldError,
} from "@/components/ui/field";
import {
  Card,
  CardHeader,
  CardTitle,
  CardDescription,
  CardContent,
  CardFooter,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Calendar } from "@/components/ui/calendar";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import useMeasure from "react-use-measure";

const TEAM_SIZE_OPTIONS = [
  { label: "Select team size", value: null },
  { label: "1-5 Members", value: "1-5" },
  { label: "5-10 Members", value: "5-10" },
  { label: "10+ Members", value: "10+" },
];

const PRIORITY_OPTIONS = [
  { label: "Select priority", value: null },
  { label: "Low", value: "Low" },
  { label: "Medium", value: "Medium" },
  { label: "High", value: "High" },
  { label: "Critical", value: "Critical" },
];

const formSchema = z.object({
  "project-name": z.string().optional(),
  "due-date": z.date().optional(),
  description: z.string().optional(),
  "team-size": z.string().nullable().optional(),
  priority: z.string().nullable().optional(),
  tag: z.array(z.string()).optional(),
  mood: z.string().optional(),
  comment: z.string().optional(),
});

type FormValues = z.infer<typeof formSchema>;

export default function MultiStepFormDemo() {
  const [currentStep, setCurrentStep] = useState(0);
  const [direction, setDirection] = useState<number>();
  const [ref, bounds] = useMeasure();

  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      "project-name": "",
      "due-date": undefined,
      description: "",
      "team-size": null,
      priority: null,
      tag: [],
      mood: "",
      comment: "",
    },
  });

  function onSubmit(values: FormValues) {
    try {
      console.log(values);
      toast(
        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
          <code className="text-white">{JSON.stringify(values, null, 2)}</code>
        </pre>
      );
    } catch (error) {
      console.error("Form submission error", error);
      toast.error("Failed to submit the form. Please try again.");
    }
  }

  const nextStep = () => {
    if (currentStep === 2) {
      form.handleSubmit(onSubmit)();
      return;
    }
    if (currentStep < 2) {
      setDirection(1);
      setCurrentStep((prev) => prev + 1);
    }
  };

  const prevStep = () => {
    if (currentStep > 0) {
      setDirection(-1);
      setCurrentStep((prev) => prev - 1);
    }
  };

  // Change Here
  const stepTitles = [
    {
      title: "Create New Project",
      description:
        "Start by providing the essential details for your workspace.",
    },
    {
      title: "Configuration",
      description: "Define team access and project priority settings.",
    },
    {
      title: "Project Kickoff Mood",
      description: "How confident do you feel about this new project?",
    },
  ];

  const watchedValues = form.watch();

  const content = useMemo(() => {
    switch (currentStep) {
      case 0:
        return (
          <div className="space-y-6 py-4">
            <Field>
              <FieldLabel htmlFor="project-name">Project Name</FieldLabel>
              <Input
                id="project-name"
                placeholder="e.g Website Design"
                {...form.register("project-name")}
              />
              <FieldError>
                {form.formState.errors["project-name"]?.message}
              </FieldError>
            </Field>

            <Field>
              <FieldLabel htmlFor="due-date">Due Date</FieldLabel>
              <Popover>
                <PopoverTrigger
                  render={
                    <Button
                      variant={"outline"}
                      className={cn(
                        "w-full justify-start text-left font-normal",
                        !watchedValues["due-date"] && "text-muted-foreground"
                      )}
                    >
                      <CalendarIcon className="mr-2 h-4 w-4" />
                      {watchedValues["due-date"] ? (
                        format(watchedValues["due-date"] as Date, "PPP")
                      ) : (
                        <span>Pick a date</span>
                      )}
                    </Button>
                  }
                />
                <PopoverContent className="w-auto p-0" align="start">
                  <Calendar
                    mode="single"
                    selected={watchedValues["due-date"]}
                    onSelect={(date) => form.setValue("due-date", date)}
                    initialFocus
                  />
                </PopoverContent>
              </Popover>
              <FieldError>
                {form.formState.errors["due-date"]?.message}
              </FieldError>
            </Field>

            <Field>
              <FieldLabel htmlFor="description">Description</FieldLabel>
              <Textarea
                id="description"
                placeholder="Describe the project goals and scope..."
                className="min-h-[100px]"
                {...form.register("description")}
              />
              <FieldError>
                {form.formState.errors.description?.message}
              </FieldError>
            </Field>
          </div>
        );
      case 1:
        return (
          <div className="space-y-6 py-4">
            <div className="grid grid-cols-2 gap-4">
              <Field>
                <FieldLabel htmlFor="team-size">Team Size</FieldLabel>
                <Select
                  items={TEAM_SIZE_OPTIONS}
                  value={watchedValues["team-size"] ?? null}
                  onValueChange={(val) => form.setValue("team-size", val)}
                >
                  <SelectTrigger id="team-size" className="w-full">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {TEAM_SIZE_OPTIONS.map((opt) => (
                      <SelectItem key={opt.label} value={opt.value as any}>
                        {opt.label}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
                <FieldError>
                  {form.formState.errors["team-size"]?.message}
                </FieldError>
              </Field>

              <Field>
                <FieldLabel htmlFor="priority">Priority</FieldLabel>
                <Select
                  items={PRIORITY_OPTIONS}
                  value={watchedValues["priority"] ?? null}
                  onValueChange={(val) => form.setValue("priority", val)}
                >
                  <SelectTrigger id="priority" className="w-full">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {PRIORITY_OPTIONS.map((opt) => (
                      <SelectItem key={opt.label} value={opt.value as any}>
                        {opt.label}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
                <FieldError>
                  {form.formState.errors.priority?.message}
                </FieldError>
              </Field>
            </div>

            <Field>
              <FieldLabel htmlFor="tag">Tags</FieldLabel>
              <div className="space-y-2">
                <div className="flex flex-wrap gap-2 mb-2">
                  {watchedValues["tag"]?.map((t, i) => (
                    <Badge key={i} variant="secondary" className="gap-1">
                      {t}
                      <button
                        type="button"
                        onClick={() => {
                          const tags = form.getValues("tag") || [];
                          form.setValue(
                            "tag",
                            tags.filter((_, index) => index !== i)
                          );
                        }}
                        className="hover:text-destructive"
                      >
                        ×
                      </button>
                    </Badge>
                  ))}
                </div>
                <Input
                  id="tag"
                  placeholder="e.g. Design, Marketing"
                  onKeyDown={(e) => {
                    if (e.key === "Enter") {
                      e.preventDefault();
                      const val = e.currentTarget.value.trim();
                      if (val) {
                        const tags = form.getValues("tag") || [];
                        if (!tags.includes(val)) {
                          form.setValue("tag", [...tags, val]);
                        }
                        e.currentTarget.value = "";
                      }
                    }
                  }}
                />
              </div>
              <FieldError>{form.formState.errors.tag?.message}</FieldError>
            </Field>
          </div>
        );
      case 2:
        return (
          <div className="space-y-4 py-4">
            <div className="rounded-xl border bg-background overflow-hidden relative">
              <div className="flex w-full border-b divide-x bg-muted/5">
                {[
                  { emoji: "😰", value: "anxious", label: "Anxious" },
                  { emoji: "😟", value: "worried", label: "Worried" },
                  { emoji: "😐", value: "neutral", label: "Neutral" },
                  { emoji: "🙂", value: "good", label: "Good" },
                  { emoji: "🤩", value: "excited", label: "Excited" },
                ].map((option) => (
                  <button
                    key={option.value}
                    className={cn(
                      "flex-1 p-3 md:p-4 text-2xl md:text-3xl transition-all hover:bg-muted focus:outline-none",
                      watchedValues["mood"] === option.value
                        ? "bg-primary/10 grayscale-0"
                        : "grayscale-[1] hover:grayscale-0"
                    )}
                    type="button"
                    title={option.label}
                    onClick={() => form.setValue("mood", option.value)}
                  >
                    {option.emoji}
                  </button>
                ))}
              </div>
              <Textarea
                id="comment"
                placeholder="Add a comment..."
                className="min-h-[140px] resize-none border-0 focus-visible:ring-0 rounded-none bg-transparent p-4 placeholder:text-muted-foreground/60"
                {...form.register("comment")}
              />
            </div>
            <p className="text-sm text-muted-foreground">
              Your feedback helps us understand the project kickoff vibe.
            </p>
          </div>
        );
      default:
        return null;
    }
  }, [currentStep, form, watchedValues]);

  const variants = {
    initial: (direction: number) => {
      return { x: `${110 * direction}%`, opacity: 0 };
    },
    animate: { x: "0%", opacity: 1 },
    exit: (direction: number) => {
      return { x: `${-110 * direction}%`, opacity: 0 };
    },
  };

  return (
    <Form {...form}>
      <MotionConfig
        transition={{
          duration: 0.5,
          type: "spring",
          bounce: 0,
        }}
      >
        <div className="flex w-full items-center justify-center bg-muted/10 p-4">
          <Card className="w-full max-w-xl shadow-none border overflow-hidden bg-background">
            <motion.div layout>
              <CardHeader className="flex flex-row items-start justify-between space-y-0 px-6 py-4">
                <div className="flex flex-col gap-1">
                  <CardTitle className="text-xl">
                    {stepTitles[currentStep].title}
                  </CardTitle>
                  <CardDescription>
                    {stepTitles[currentStep].description}
                  </CardDescription>
                </div>
                <div className="flex items-center gap-1.5 pt-1">
                  {stepTitles.map((_, index) => (
                    <div
                      key={index}
                      className={cn(
                        "h-2 rounded-full transition-all duration-300",
                        currentStep === index
                          ? "w-8 bg-primary"
                          : "w-2 bg-primary/20"
                      )}
                    />
                  ))}
                </div>
              </CardHeader>

              <motion.div
                animate={{ height: bounds.height > 0 ? bounds.height : "auto" }}
                className="relative overflow-hidden"
                transition={{ type: "spring", bounce: 0, duration: 0.5 }}
              >
                <div ref={ref}>
                  <CardContent className="px-6 py-2 relative">
                    <AnimatePresence
                      mode="popLayout"
                      initial={false}
                      custom={direction}
                    >
                      <motion.div
                        key={currentStep}
                        variants={variants}
                        initial="initial"
                        animate="animate"
                        exit="exit"
                        className="w-full"
                        custom={direction}
                      >
                        {content}
                      </motion.div>
                    </AnimatePresence>
                  </CardContent>
                </div>
              </motion.div>

              <CardFooter className="flex justify-between items-center border-t py-4">
                <Button
                  variant={"secondary"}
                  type="button"
                  onClick={prevStep}
                  disabled={currentStep === 0}
                >
                  <ChevronLeft className="h-4 w-4" />
                  Back
                </Button>
                <Button type="button" onClick={nextStep}>
                  {currentStep === stepTitles.length - 1 ? (
                    <>
                      Finish <Check className="h-4 w-4" />
                    </>
                  ) : (
                    <>
                      Continue <ChevronRight className="h-4 w-4" />
                    </>
                  )}
                </Button>
              </CardFooter>
            </motion.div>
          </Card>
        </div>
      </MotionConfig>
    </Form>
  );
}

Installation

npx shadcn@latest add "https://uselayouts.com/r/multi-step-form.json"

Install dependencies

npm install react-hook-form @hookform/resolvers zod motion react-use-measure date-fns

Copy the code

Copy the code from the Code tab above into components/multi-step-form.tsx.

Update imports

Update the imports to match your project structure.

Usage

Customizing Content

You can easily update the step titles and descriptions by modifying the stepTitles array inside the component:

// Change Here
const stepTitles = [
  {
    title: "Create New Project",
    description: "Start by providing the essential details.",
  },
  // ...
];

Step Content

The actual form fields for each step are defined in the content useMemo block. You can add or modify fields here:

const content = useMemo(() => {
  switch (currentStep) {
    case 0:
      return (
        <div className="space-y-6 py-4">
          <Field>
            <FieldLabel htmlFor="project-name">Project Name</FieldLabel>
            <Input id="project-name" {...form.register("project-name")} />
          </Field>
          {/* Add more fields for Step 1 */}
        </div>
      );
    // Add more cases for each step index...
  }
}, [currentStep, form]);
import MultiStepForm from "@/components/multi-step-form";

export default function Page() {
  return (
    <div className="max-w-xl mx-auto py-12">
      <MultiStepForm />
    </div>
  );
}

Features

  • Progress Tracking: Clear visual indication of the current step and overall progress.
  • Animated Transitions: Smooth spring-based animations between steps using Framer Motion.
  • Form Validation: Integrated with React Hook Form and Zod for robust client-side validation.
  • Responsive Height: Automatically adjusts its height based on the content of each step.
  • Customizable: Easily add or remove steps to fit your workflow.
  • Interactive Elements: Includes a custom tag input and mood selector.