Stepper
Multi-step progress with animated checkmarks and connectors.
- Account
- 2Workspace
- 3Invite
- 4Done
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>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/stepper.tsxcomponents/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> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.