Customize
Usage
import { Stepper } from "@/components/motionkit/stepper";
const [step, setStep] = useState(1);
<Stepper steps={["Account", "Workspace", "Billing", "Done"]} current={step} />Motion
The exact curves this component runs, plotted with Motion's own maths.
Line and dots
Step changes
Tick draw
A step completes
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/stepper.tsx.
"use client";
import { motion, useReducedMotion } from "motion/react";
type StepperProps = {
steps: string[];
/** Index of the step in progress. Earlier steps show as done. */
current: number;
/** Colour for completed steps and the progress line. */
color?: string;
className?: string;
};
export function Stepper({ steps, current, color = "#0E7C86", className = "" }: StepperProps) {
const reduceMotion = useReducedMotion();
const spring = reduceMotion ? { duration: 0 } : { type: "spring" as const, bounce: 0.15, duration: 0.6 };
const progress = steps.length > 1 ? Math.min(current, steps.length - 1) / (steps.length - 1) : 0;
return (
<div className={`relative ${className}`}>
{/* track and fill sit behind the dots, between the first and last centres */}
<div aria-hidden className="absolute left-4 right-4 top-4 h-0.5 -translate-y-1/2 rounded-full bg-white/10">
<motion.div
className="h-full origin-left rounded-full"
style={{ backgroundColor: color }}
initial={false}
animate={{ scaleX: progress }}
transition={spring}
/>
</div>
<ol className="relative flex justify-between">
{steps.map((step, index) => {
const done = index < current;
const active = index === current;
return (
<li key={step} aria-current={active ? "step" : undefined} className="flex w-8 flex-col items-center">
<motion.span
initial={false}
animate={{
backgroundColor: done ? color : "#141B1B",
borderColor: done || active ? color : "rgba(255,255,255,0.12)",
scale: active && !reduceMotion ? 1.08 : 1,
}}
transition={spring}
className="relative flex size-8 items-center justify-center rounded-full border-2 text-xs font-medium text-[#F6F1E8]"
>
{done ? (
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="#fff" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
<motion.path
d="M5 12.5 10 17 19 7"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.35, ease: "easeOut", delay: reduceMotion ? 0 : 0.15 }}
/>
</svg>
) : (
index + 1
)}
</motion.span>
<span className={`mt-2 whitespace-nowrap text-xs ${active ? "text-[#F6F1E8]" : "text-[#94A9A7]"}`}>
{step}
{done && <span className="sr-only"> (completed)</span>}
</span>
</li>
);
})}
</ol>
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | string[] | — | Step labels in order. |
| current | number | — | Index of the step in progress. |
| color | string | "#0E7C86" | Colour for completed steps and the line. |
Toast
Stacking notifications with swipe-to-dismiss, hover-to-pause and variants.
Hold to Confirm
A button that fills while held, so destructive actions can never happen by accident.