Overview

A multi-step card with a progress rail across the top: the dark line fills toward the current step, finished steps draw a check, and a halo slides to the active step. Finished steps can be clicked to go back. Each step slides in from the direction you're heading, through a soft blur, while the card's height follows its content. The primary button's label crossfades from Continue to the finish label. A step can hold the user with canContinue. If onComplete returns a promise, the button shows a busy label until it settles, then the wizard can swap to a completion screen.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/step-wizard.json

Install dependencies:

$ pnpm add lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/step-wizard.tsx
tsx
"use client";
import React, { useCallback, useEffect, useId, useRef, useState } from "react";import { AnimatePresence, motion, useReducedMotion } from "motion/react";import { ArrowLeft } from "lucide-react";import { cn } from "@/lib/utils";
export type StepWizardStep = {  id: string;  title: string;  description?: string;  content: React.ReactNode;  /** Set to false to hold the user on this step (e.g. a required field is empty). */  canContinue?: boolean;};
export type StepWizardProps = {  steps: StepWizardStep[];  /** Controlled step index. */  step?: number;  defaultStep?: number;  onStepChange?: (index: number) => void;  /** Called from the last step. Return a promise to show a busy state until it settles. */  onComplete?: () => void | Promise<unknown>;  /** Shown in place of the steps once `onComplete` resolves. Receives a reset function. */  completeContent?: React.ReactNode | ((reset: () => void) => React.ReactNode);  className?: string;  nextLabel?: string;  backLabel?: string;  finishLabel?: string;};
const EASE_OUT = [0.23, 1, 0.32, 1] as const;const HEIGHT_SPRING = { type: "spring" as const, bounce: 0, duration: 0.42 };
function NodeGlyph({ index, done, reduce }: { index: number; done: boolean; reduce: boolean }) {  return (    <AnimatePresence initial={false} mode="popLayout">      {done ? (        <motion.svg          key="check"          viewBox="0 0 16 16"          className="size-3.5"          fill="none"          stroke="currentColor"          strokeWidth="2.4"          strokeLinecap="round"          strokeLinejoin="round"          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}          animate={{ opacity: 1, scale: 1 }}          exit={{ opacity: 0, scale: 0.5 }}          transition={{ type: "spring", bounce: 0.35, duration: 0.4 }}        >          <motion.path            d="M3.5 8.5l3 3 6-7"            initial={{ pathLength: reduce ? 1 : 0 }}            animate={{ pathLength: 1 }}            transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.05 }}          />        </motion.svg>      ) : (        <motion.span          key="num"          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(2px)" }}          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}          exit={{ opacity: 0, scale: 0.5 }}          transition={{ duration: 0.25, ease: EASE_OUT }}        >          {index + 1}        </motion.span>      )}    </AnimatePresence>  );}
export function StepWizard({  steps,  step: stepProp,  defaultStep = 0,  onStepChange,  onComplete,  completeContent,  className,  nextLabel = "Continue",  backLabel = "Back",  finishLabel = "Finish",}: StepWizardProps) {  const reduce = !!useReducedMotion();  const uid = useId();  const isControlled = stepProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(defaultStep);  const last = steps.length - 1;  const index = Math.min(Math.max(isControlled ? stepProp : uncontrolled, 0), last);  const [direction, setDirection] = useState(1);  const [busy, setBusy] = useState(false);  const [complete, setComplete] = useState(false);  const mountedRef = useRef(true);  useEffect(() => {    mountedRef.current = true;    return () => {      mountedRef.current = false;    };  }, []);
  // Panel height follows its content, including content that changes later.  const innerRef = useRef<HTMLDivElement>(null);  const [height, setHeight] = useState<number | "auto">("auto");  useEffect(() => {    const el = innerRef.current;    if (!el) return;    const ro = new ResizeObserver(([entry]) => setHeight(entry.contentRect.height));    ro.observe(el);    return () => ro.disconnect();  }, []);
  const current = steps[index];  const isLast = index === last;  const blocked = current?.canContinue === false;
  const setStep = useCallback(    (next: number) => {      const n = Math.min(Math.max(next, 0), last);      if (n === index) return;      setDirection(n > index ? 1 : -1);      if (!isControlled) setUncontrolled(n);      onStepChange?.(n);    },    [index, isControlled, last, onStepChange],  );
  const finish = async () => {    if (busy) return;    const result = onComplete?.();    if (result && typeof (result as Promise<unknown>).then === "function") {      setBusy(true);      try {        await result;      } catch {        if (mountedRef.current) setBusy(false);        return;      }      if (!mountedRef.current) return;      setBusy(false);    }    if (completeContent !== undefined) {      setDirection(1);      setComplete(true);    }  };
  const reset = useCallback(() => {    setDirection(-1);    setComplete(false);    if (!isControlled) setUncontrolled(0);    onStepChange?.(0);  }, [isControlled, onStepChange]);
  if (!current) return null;
  const progress = complete ? 1 : last === 0 ? 1 : index / last;  const primaryLabel = busy ? "Working…" : isLast ? finishLabel : nextLabel;  const panelKey = complete ? "__complete" : current.id;
  const variants = {    enter: (dir: number) =>      reduce ? { opacity: 0 } : { opacity: 0, x: dir * 36, filter: "blur(6px)" },    center: { opacity: 1, x: 0, filter: "blur(0px)" },    exit: (dir: number) =>      reduce ? { opacity: 0 } : { opacity: 0, x: dir * -36, filter: "blur(6px)" },  };
  return (    <div      className={cn(        "w-full overflow-hidden rounded-[20px] bg-(--color-paper)",        "shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04),0_12px_32px_-12px_rgba(0,0,0,0.12)]",        className,      )}    >      {/* Track */}      <div className="px-5 pt-5 sm:px-6">        <div className="mb-3 flex items-center justify-between text-[12px] text-(--color-neutral) tabular-nums">          <span aria-hidden>            {complete ? "All done" : `Step ${index + 1} of ${steps.length}`}          </span>          <span aria-hidden>{Math.round(progress * 100)}%</span>        </div>        <div role="list" className="relative flex items-center justify-between" aria-label="Progress">          {/* Rail + fill sit behind the nodes */}          <div aria-hidden className="absolute inset-x-3 top-1/2 h-[2px] -translate-y-1/2 rounded-full bg-(--color-rule)">            <motion.div              className="h-full origin-left rounded-full bg-(--color-ink)"              initial={false}              animate={{ scaleX: progress }}              transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0, duration: 0.6 }}            />          </div>          {steps.map((s, i) => {            const done = complete || i < index;            const active = !complete && i === index;            const reachable = !complete && !busy && i < index;            return (              <div role="listitem" key={s.id} className="relative z-1">                <button                  type="button"                  aria-current={active ? "step" : undefined}                  aria-label={`${s.title}${active ? ", current step" : done ? ", completed" : ""}`}                  disabled={!reachable && !active}                  onClick={() => reachable && setStep(i)}                  className={cn(                    "group relative flex size-7 items-center justify-center rounded-full text-[11.5px] font-semibold tabular-nums outline-none",                    "transition-[background-color,color,box-shadow,transform] duration-300 ease-out",                    "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",                    reachable && "cursor-pointer hover:scale-110 active:scale-95",                    done || active                      ? "bg-(--color-ink) text-(--color-paper)"                      : "bg-(--color-paper) text-(--color-neutral) shadow-[inset_0_0_0_1.5px_var(--color-rule)]",                  )}                >                  {active && !reduce ? (                    <motion.span                      layoutId={`${uid}-halo`}                      aria-hidden                      transition={{ type: "spring", bounce: 0.2, duration: 0.5 }}                      className="absolute -inset-[5px] rounded-full shadow-[0_0_0_1.5px_var(--color-ink)] opacity-20"                    />                  ) : null}                  <NodeGlyph index={i} done={done} reduce={reduce} />                  <span className="sr-only">{s.title}</span>                </button>              </div>            );          })}        </div>      </div>
      {/* Panel */}      <motion.div        initial={false}        animate={{ height }}        transition={reduce ? { duration: 0 } : HEIGHT_SPRING}        className="relative overflow-hidden"      >        <div ref={innerRef} className="relative">          <AnimatePresence initial={false} mode="popLayout" custom={direction}>            <motion.section              key={panelKey}              custom={direction}              variants={variants}              initial="enter"              animate="center"              exit="exit"              transition={{ duration: 0.34, ease: EASE_OUT }}              aria-labelledby={complete ? undefined : `${uid}-title`}              className="px-5 pt-5 pb-6 sm:px-6"            >              {complete ? (                typeof completeContent === "function" ? (                  completeContent(reset)                ) : (                  completeContent                )              ) : (                <>                  <div                    id={`${uid}-title`}                    className="text-[17px] leading-snug font-semibold tracking-[-0.01em] text-balance text-(--color-ink)"                  >                    {current.title}                  </div>                  {current.description ? (                    <div className="mt-1 text-[13.5px] leading-relaxed text-(--color-neutral)">                      {current.description}                    </div>                  ) : null}                  <motion.div                    initial={reduce ? false : { opacity: 0, y: 8 }}                    animate={{ opacity: 1, y: 0 }}                    transition={{ duration: 0.35, ease: EASE_OUT, delay: 0.08 }}                    className="mt-5"                  >                    {current.content}                  </motion.div>                </>              )}            </motion.section>          </AnimatePresence>        </div>      </motion.div>
      {/* Actions */}      <AnimatePresence initial={false}>        {!complete ? (          <motion.div            initial={{ height: 0, opacity: 0 }}            animate={{ height: "auto", opacity: 1 }}            exit={{ height: 0, opacity: 0 }}            transition={reduce ? { duration: 0 } : HEIGHT_SPRING}            className="overflow-hidden"          >            <div className="flex items-center justify-between gap-3 border-t border-(--color-rule) px-5 py-3.5 sm:px-6">              <motion.button                type="button"                disabled={index === 0 || busy}                onClick={() => setStep(index - 1)}                animate={{ opacity: index === 0 ? 0 : 1, x: index === 0 && !reduce ? -6 : 0 }}                transition={{ duration: 0.25, ease: EASE_OUT }}                className={cn(                  "inline-flex h-9 items-center gap-1.5 rounded-full px-3 text-[13.5px] font-medium text-(--color-ink-2) outline-none",                  "transition-[background-color,color,transform] duration-150 hover:bg-(--color-paper-2) hover:text-(--color-ink) active:scale-[0.96]",                  "focus-visible:ring-2 focus-visible:ring-(--color-focus)",                  "disabled:pointer-events-none",                )}              >                <ArrowLeft className="size-3.5" strokeWidth={2} aria-hidden />                {backLabel}              </motion.button>              <motion.button                type="button"                layout={reduce ? false : true}                disabled={blocked || busy}                onClick={() => (isLast ? finish() : setStep(index + 1))}                transition={{ type: "spring", bounce: 0.15, duration: 0.4 }}                className={cn(                  "relative inline-flex h-9 min-w-24 items-center justify-center overflow-hidden rounded-full px-4 text-[13.5px] font-medium outline-none",                  "bg-(--color-ink) text-(--color-paper) shadow-[inset_0_1px_0_rgba(255,255,255,0.14),0_1px_2px_rgba(0,0,0,0.12)]",                  "transition-[opacity,transform] duration-150 active:scale-[0.97]",                  "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",                  "disabled:cursor-not-allowed",                  blocked && "opacity-40",                )}              >                <AnimatePresence initial={false} mode="popLayout">                  <motion.span                    key={primaryLabel}                    layout="position"                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10, filter: "blur(4px)" }}                    animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}                    exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10, filter: "blur(4px)" }}                    transition={{ duration: 0.26, ease: EASE_OUT }}                    className="inline-block whitespace-nowrap"                  >                    {primaryLabel}                  </motion.span>                </AnimatePresence>              </motion.button>            </div>          </motion.div>        ) : null}      </AnimatePresence>
      <div role="status" aria-live="polite" className="sr-only">        {complete ? "Completed" : `Step ${index + 1} of ${steps.length}: ${current.title}`}      </div>    </div>  );}

Adjust import paths according to your folder structure.

Ensure transitions.dev CSS is available (.t-page-slide, .t-icon-swap, .t-resize, .t-text-swap). This site imports them from styles/transitions-dev/.


Basic Usage

tsx
import { StepWizard } from "@/components/ui/step-wizard";
<StepWizard  steps={[    { id: "name", title: "Name your project", content: <NameField />, canContinue: name.length > 1 },    { id: "team", title: "Invite your team", content: <TeamPicker /> },    { id: "review", title: "Review and create", content: <Summary /> },  ]}  finishLabel="Create project"  onComplete={() => createProject()} // may return a promise  completeContent={(reset) => <Done onStartOver={reset} />}/>

Props

PropTypeRequiredDefaultDescription
stepsStepWizardStep[]Yes—Steps with id, title, optional description, content, and canContinue (false disables the primary button).
step / defaultStepnumberNo0Controlled or initial step index.
onStepChange(index: number) => voidNo—Fires on Continue, Back, a click on a finished step, or a reset.
onComplete() => void | Promise<unknown>No—Runs from the last step. A returned promise shows a busy state; a rejection keeps the user on the last step.
completeContentReactNode | (reset: () => void) => ReactNodeNo—Shown after onComplete succeeds. The function form gets a reset that returns to step one.
nextLabel / backLabel / finishLabelstringNo"Continue" / "Back" / "Finish"Button labels.
classNamestringNo—Class names on the card.

Best Practices

  1. Use canContinue for required input rather than showing errors after the click.
  2. Keep each step to one decision, and put a summary on the last step.
  3. Return the real request from onComplete, so the busy state matches the actual wait.
  4. Step changes are announced to screen readers. Reduced motion swaps the slides for fades.