Wizard progress with connector lines that fill as steps complete, blur-swapped check marks, and a one-shot ring pulse on the current step.
npx shadcn@latest add @paragon/stepper"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface StepperStep {
title: string;
description?: string;
}
export interface StepperProps extends React.ComponentProps<"ol"> {
steps: StepperStep[];
/** Zero-based index of the active step; everything before it is complete. */
step?: number;
/** When provided, completed circles become buttons that jump back. */
onStepClick?: (index: number) => void;
}
/**
* Wizard progress: numbered circles joined by connector lines that fill with
* a scaleX ease-in-out as steps complete. Completed circles blur-swap their
* number for a check; the current circle emits a single (non-looping) ring
* pulse when it becomes active.
*/
export function Stepper({
steps,
step = 0,
onStepClick,
className,
...props
}: StepperProps) {
const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const reducedMotion = useReducedMotion();
const current = Math.min(Math.max(step, 0), steps.length - 1);
const swapTransition = reducedMotion
? { duration: 0 }
: ({ type: "spring", duration: 0.3, bounce: 0 } as const);
return (
<ol className={cn("flex w-full items-start", className)} {...props}>
<style href={`paragon-stepper-${id}`} precedence="paragon">{`
@keyframes stepper-pulse-${id} {
from { opacity: 0.5; transform: scale(1); }
to { opacity: 0; transform: scale(1.55); }
}
@media (prefers-reduced-motion: reduce) {
[data-stepper-pulse="${id}"] { animation: none !important; }
}
`}</style>
{steps.map((item, index) => {
const complete = index < current;
const active = index === current;
const clickable = Boolean(onStepClick) && complete;
const circleClass = cn(
"relative flex size-7 items-center justify-center rounded-full text-xs font-medium tabular-nums transition-[background-color,color,box-shadow] duration-200 ease-out",
complete && "bg-primary text-primary-foreground",
active &&
"bg-background text-foreground shadow-[0_0_0_1.5px_var(--color-primary)]",
!complete && !active && "bg-background text-muted-foreground shadow-border",
);
const circleContent = (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={complete ? "check" : "number"}
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={swapTransition}
className="flex items-center justify-center"
>
{complete ? <Check className="size-3.5" /> : index + 1}
</motion.span>
</AnimatePresence>
);
return (
<li
key={item.title}
aria-current={active ? "step" : undefined}
className="relative flex flex-1 flex-col items-center gap-2 px-1"
>
{index > 0 && (
<span
aria-hidden
className="absolute top-3.5 right-[calc(50%+22px)] left-[calc(-50%+22px)] h-px bg-border"
>
<span
className={cn(
"absolute inset-0 origin-left bg-primary transition-transform duration-300 ease-in-out motion-reduce:transition-none",
index <= current ? "scale-x-100" : "scale-x-0",
)}
/>
</span>
)}
<span className="relative">
{active && (
<span
key={`pulse-${current}`}
aria-hidden
data-stepper-pulse={id}
className="pointer-events-none absolute -inset-px rounded-full shadow-[0_0_0_1.5px_var(--color-primary)]"
style={{
animation: `stepper-pulse-${id} 500ms var(--ease-out) 1 both`,
}}
/>
)}
{clickable ? (
<button
type="button"
aria-label={`Return to step ${index + 1}: ${item.title}`}
onClick={() => onStepClick?.(index)}
className={cn(
circleClass,
"pressable after:absolute after:-inset-2",
)}
>
{circleContent}
</button>
) : (
<span className={circleClass}>{circleContent}</span>
)}
</span>
<span className="flex flex-col items-center gap-0.5 text-center">
<span
className={cn(
"text-[13px] font-medium transition-colors duration-200 ease-out",
active || complete ? "text-foreground" : "text-muted-foreground",
)}
>
{item.title}
</span>
{item.description && (
<span className="hidden max-w-36 text-xs text-muted-foreground sm:block">
{item.description}
</span>
)}
</span>
</li>
);
})}
</ol>
);
}