Navigation

Stepper

Wizard progress with connector lines that fill as steps complete, blur-swapped check marks, and a one-shot ring pulse on the current step.

Install

npx shadcn@latest add @paragon/stepper

stepper.tsx

"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>
  );
}