Stepper

Multi-step progress with animated checkmarks and connectors.

  1. Account
  2. 2Workspace
  3. 3Invite
  4. 4Done

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/stepper.tsx

    components/ui/stepper.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { Check } from "lucide-react";
    import { useState } from "react";
    
    export function Stepper({ steps, current }: { steps: string[]; current: number }) {
      return (
        <ol className="flex w-full items-center">
          {steps.map((label, i) => {
            const done = i < current;
            const active = i === current;
            return (
              <li key={label} className={`flex items-center ${i < steps.length - 1 ? "flex-1" : ""}`} aria-current={active ? "step" : undefined}>
                <div className="flex flex-col items-center gap-2">
                  <motion.span
                    animate={{ backgroundColor: done || active ? "#18181b" : "#ffffff", borderColor: done || active ? "#18181b" : "#e4e4e7", scale: active ? 1.1 : 1 }}
                    className="grid size-9 place-items-center rounded-full border-2 text-sm font-semibold"
                  >
                    <AnimatePresence mode="wait" initial={false}>
                      {done ? (
                        <motion.span key="check" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="text-white">
                          <Check className="size-4" />
                        </motion.span>
                      ) : (
                        <motion.span key="num" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className={active ? "text-white" : "text-zinc-400"}>
                          {i + 1}
                        </motion.span>
                      )}
                    </AnimatePresence>
                  </motion.span>
                  <span className={`whitespace-nowrap text-xs ${active ? "font-medium text-zinc-900" : "text-zinc-500"}`}>{label}</span>
                </div>
                {i < steps.length - 1 && (
                  <div className="mx-2 mb-6 h-0.5 flex-1 overflow-hidden rounded bg-zinc-200">
                    <motion.div className="h-full bg-zinc-900" initial={false} animate={{ width: done ? "100%" : "0%" }} transition={{ duration: 0.4 }} />
                  </div>
                )}
              </li>
            );
          })}
        </ol>
      );
    }
    
    const steps = ["Account", "Workspace", "Invite", "Done"];
    
    export default function Demo() {
      const [step, setStep] = useState(1);
      return (
        <div className="w-full max-w-lg rounded-2xl border border-zinc-200 bg-white p-6">
          <Stepper steps={steps} current={step} />
          <div className="mt-6 flex justify-between">
            <button onClick={() => setStep((s) => Math.max(0, s - 1))} disabled={step === 0} className="rounded-lg border border-zinc-200 px-4 py-2 text-sm text-zinc-700 hover:bg-zinc-50 disabled:opacity-40">
              Back
            </button>
            <button onClick={() => setStep((s) => Math.min(steps.length - 1, s + 1))} disabled={step === steps.length - 1} className="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-40">
              {step === steps.length - 2 ? "Finish" : "Continue"}
            </button>
          </div>
        </div>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.