Skip to content
Component

Stepper

A steps indicator for multi-step flows such as onboarding or checkout.

  • #arc
  • #component
  • #steps
  • #progress
  • #flow
  • #navigation

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/stepper.json

stepper.module.csscss
.root { --marker: 1.75rem; --halo: 4px; min-width: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
/* A horizontal stepper spans its container, so it can safely measure itself and fold into markers when space runs out. */
.horizontal { width: 100%; container: stepper / inline-size; }
.list { display: grid; margin: 0; padding: 0; list-style: none; }
.item { position: relative; min-width: 0; }
.horizontal .item:not(:last-child) { padding-inline-end: var(--space-4); }
.vertical .item:not(:last-child) { padding-bottom: var(--space-5); }
.compact.vertical .item:not(:last-child) { padding-bottom: var(--space-6); }

/* Buttons and spans share one head, so a chosen step keeps focus as it becomes the current one. */
.head { display: grid; margin: 0; padding: 0; border: 0; border-radius: var(--space-2); background: none; color: inherit; font: inherit; letter-spacing: inherit; text-align: start; cursor: default; -webkit-tap-highlight-color: transparent; }
.head[data-clickable] { cursor: pointer; }
.horizontal .head { width: fit-content; max-width: 100%; justify-items: start; row-gap: var(--space-3); }
.vertical .head { width: 100%; grid-template-columns: var(--marker) minmax(0, 1fr); column-gap: var(--space-3); align-items: start; }
.compact.vertical .head { grid-template-columns: var(--marker); column-gap: 0; }
.head:focus-visible { --focus-outline-offset: 4px; }
.head:focus:not(:focus-visible) { outline: none; }

/* The ring sits behind the disc and grows out of it; the disc carries the state color and the glyph. */
.marker { position: relative; display: grid; width: var(--marker); height: var(--marker); flex: none; place-items: center; }
.ring { position: absolute; inset: calc(var(--halo) * -1); border-radius: var(--radius-pill); background: var(--accent-subtle); transition: background-color var(--duration-standard) var(--ease-standard); }
.disc { position: relative; display: grid; width: 100%; height: 100%; place-items: center; border-radius: var(--radius-pill); background: var(--surface); box-shadow: inset 0 0 0 1px var(--border-strong); color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; font-variant-numeric: tabular-nums; line-height: 1; transition: background-color var(--duration-standard) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
.glyph { display: grid; grid-area: 1 / 1; place-items: center; }
.icon { width: .875rem; height: .875rem; }
.item[data-status="current"] .disc { box-shadow: inset 0 0 0 1.5px var(--accent); color: var(--foreground); }
.item[data-status="complete"] .disc { background: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color: var(--accent-foreground); }
.item[data-status="error"] .disc { background: color-mix(in oklab, var(--danger) 10%, var(--surface)); box-shadow: inset 0 0 0 1.5px var(--danger); color: var(--danger); }
.item[data-status="error"] .ring { background: color-mix(in oklab, var(--danger) 16%, transparent); }

/* The track stays put; only the fill scales along it on a spring, from the step that was just finished. */
.connector { position: absolute; overflow: hidden; border-radius: var(--radius-pill); background: var(--border); pointer-events: none; }
.horizontal .connector { top: calc(var(--marker) / 2 - 1px); inset-inline: calc(var(--marker) + var(--halo) + var(--space-1)) calc(var(--halo) + var(--space-1)); height: 2px; }
/* Both ends keep a clear gap from the markers, and from the current step's ring. */
.vertical .connector { top: calc(var(--marker) + var(--halo) + 2px); bottom: calc(var(--halo) + 2px); inset-inline-start: calc(var(--marker) / 2 - 1px); width: 2px; }
.fill { position: absolute; inset: 0; border-radius: inherit; background: var(--accent); }
.horizontal .fill { transform-origin: left center; }
.horizontal .fill:dir(rtl) { transform-origin: right center; }
.vertical .fill { transform-origin: center top; }

.text { display: grid; min-width: 0; }
/* Centers the first line of the label on the marker. */
.vertical .text { padding-top: .25rem; }
.label { display: block; color: var(--text-muted); font-size: var(--text-sm); font-weight: 500; line-height: 1.25rem; overflow-wrap: break-word; transition: color var(--duration-standard) var(--ease-standard); }
.item[data-status="complete"] .label { color: var(--text-secondary); }
.item[data-status="current"] .label, .item[data-status="error"] .label { color: var(--foreground); }
/* Changing text pops out of flow against this box while it leaves, so it must be the positioned parent. */
.slot { display: block; }
.slotInner { position: relative; display: block; }
.description, .error { display: block; padding-top: 2px; font-size: var(--text-xs); font-weight: 400; line-height: var(--leading-body); overflow-wrap: break-word; }
.description { color: var(--text-muted); }
.error { color: var(--danger); }

.caption { display: none; margin-top: var(--space-3); }
.captionLabel { display: block; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: 1.25rem; }

@media (hover: hover) and (pointer: fine) {
  .head[data-clickable]:hover .label { color: var(--foreground); }
  .item[data-status="complete"] .head[data-clickable]:hover .disc { background: var(--accent-strong); box-shadow: inset 0 0 0 1px var(--accent-strong); }
  .item[data-status="error"] .head[data-clickable]:hover .disc { background: color-mix(in oklab, var(--danger) 18%, var(--surface)); }
}
.head[data-clickable]:active .disc { transform: scale(.92); }

.srOnly, .compact .text { position: absolute; width: 1px; height: 1px; margin: 0; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.compact.horizontal .caption { display: grid; }
/* Four labels no longer fit side by side: keep the markers and name the current step underneath. */
@container stepper (max-width: 30rem) {
  .horizontal .text { position: absolute; width: 1px; height: 1px; margin: 0; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
  .horizontal .caption { display: grid; }
}

@media (prefers-reduced-motion: reduce) {
  .disc, .ring, .label { transition: none; }
  .head[data-clickable]:active .disc { transform: none; }
}
stepper.tsxtsx
"use client";

import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { AnimatePresence, animate, motion, useMotionValue } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import { playUiSound } from "../sound/play";
import styles from "./stepper.module.css";

export type StepperOrientation = "horizontal" | "vertical";
export type StepperStatus = "complete" | "current" | "upcoming" | "error";

export interface StepperStep {
  /** Stable key, so each step keeps its own motion when the list changes. */
  id: string;
  label: string;
  /** A short hint under the label. */
  description?: string;
  /** Marks the step as failed: the marker morphs into an alert and this message replaces the description. */
  error?: string;
}

/**
 * A steps indicator for onboarding, checkout, and setup flows. Place it above (horizontal) or beside (vertical) the step
 * content and drive it with `current`; set `current` to `steps.length` once every step is done. Pass `onStepSelect` to let
 * people return to completed steps by click or keyboard. For a multi-step form that owns its own content, use MultiStepForm.
 */
export interface StepperProps {
  steps: StepperStep[];
  /** Index of the step in progress. `steps.length` marks the whole flow complete. */
  current: number;
  orientation?: StepperOrientation;
  /** Called with the index of a completed step when it is chosen. Without it the stepper is a read-only indicator. */
  onStepSelect?: (index: number) => void;
  /** `current` shows only the active step's description, for tight spaces. Errors always show. */
  details?: "all" | "current";
  /** Markers only; labels stay available to assistive tech. Horizontal steppers switch to this below 30rem on their own
   *  and show the current step's label underneath. */
  compact?: boolean;
  /** Accessible name for the stepper. */
  label?: string;
  /** Announced, and shown in the compact caption, once every step is complete. */
  completeLabel?: string;
  className?: string;
}

type GlyphKind = "number" | "check" | "error";

const blur = (radius: number) => `blur(${radius}px)`;
const still = { duration: 0 } as const;
const leave = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } as const;
const settle = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } as const;
const textFrom = { opacity: 0, y: "0.3em", filter: blur(motionTokens.blur.soft) };
const textRest = { opacity: 1, y: 0, filter: blur(0) };
const textGone = { opacity: 0, y: "-0.3em", filter: blur(motionTokens.blur.subtle) };
const glyphFrom = { opacity: 0, scale: .5, filter: blur(motionTokens.blur.subtle) };
const glyphRest = { opacity: 1, scale: 1, filter: blur(0) };

/** Text that rises in with a small blur when it changes. Its slot springs to the new height, so a message that wraps
 *  eases the steps below it down instead of pushing them; unrelated resizes (fonts, container width) follow exactly. */
function SwapText({ text, className, reduced }: { text?: string; className?: string; reduced: boolean }) {
  const inner = useRef<HTMLSpanElement>(null);
  const armedUntil = useRef(0);
  const height = useMotionValue<number | "auto">("auto");
  useLayoutEffect(() => { armedUntil.current = performance.now() + 700; }, [text]);
  useEffect(() => {
    const node = inner.current, slot = node?.parentElement;
    if (!node || !slot || typeof ResizeObserver === "undefined") return;
    let measured = false;
    const observer = new ResizeObserver(() => {
      const next = node.offsetHeight;
      if (!measured || reduced || performance.now() > armedUntil.current) { measured = true; height.jump(next); return; }
      animate(height, next, motionTokens.spring.smooth);
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, [height, reduced]);
  return <motion.span className={styles.slot} style={{ height }}>
    <span ref={inner} className={styles.slotInner}>
      <AnimatePresence mode="popLayout" initial={false}>
        {text ? <motion.span key={`${className}:${text}`} className={className} initial={textFrom} animate={textRest} exit={{ ...textGone, transition: reduced ? still : leave }} transition={reduced ? still : settle}>{text}</motion.span> : null}
      </AnimatePresence>
    </span>
  </motion.span>;
}

/** The number, check, and alert share one spot: the outgoing glyph shrinks away while the next one pops in and draws its stroke. */
function Glyph({ kind, number, delay, reduced }: { kind: GlyphKind; number: number; delay: number; reduced: boolean }) {
  const pop = reduced ? still : { scale: { ...motionTokens.spring.snappy, delay }, opacity: { duration: motionTokens.duration.fast, delay }, filter: { duration: motionTokens.duration.fast, delay } };
  const exit = { ...glyphFrom, transition: reduced ? still : leave };
  const draw = reduced ? still : { duration: motionTokens.duration.standard, ease: motionTokens.ease.enter, delay: delay + .04 };
  if (kind === "number") return <motion.span className={styles.glyph} initial={glyphFrom} animate={glyphRest} exit={exit} transition={pop}>{number}</motion.span>;
  return <motion.svg className={`${styles.glyph} ${styles.icon}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" initial={glyphFrom} animate={glyphRest} exit={exit} transition={pop}>
    {kind === "check"
      ? <motion.path d="M5.5 12.5l4.25 4.25L18.5 8" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={draw} />
      : <>
        <motion.path d="M12 6.75v6.5" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={draw} />
        <motion.circle cx={12} cy={17.4} r={1.4} fill="currentColor" stroke="none" initial={{ scale: 0 }} animate={{ scale: 1 }} transition={reduced ? still : { ...motionTokens.spring.snappy, delay: delay + .16 }} />
      </>}
  </motion.svg>;
}

/** The ring grows out from behind the disc once progress arrives, so the eye lands on the new step after the connector fills. */
function Marker({ number, kind, current, glyphDelay, ringDelay, reduced }: { number: number; kind: GlyphKind; current: boolean; glyphDelay: number; ringDelay: number; reduced: boolean }) {
  return <span className={styles.marker} aria-hidden="true">
    <AnimatePresence initial={false}>
      {current ? <motion.span key="ring" className={styles.ring} initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6, transition: reduced ? still : leave }}
        transition={reduced ? still : { scale: { ...motionTokens.spring.snappy, delay: ringDelay }, opacity: { duration: motionTokens.duration.fast, delay: ringDelay } }} /> : null}
    </AnimatePresence>
    <span className={styles.disc}>
      <AnimatePresence initial={false}><Glyph key={kind} kind={kind} number={number} delay={glyphDelay} reduced={reduced} /></AnimatePresence>
    </span>
  </span>;
}

const statusText: Record<StepperStatus, string> = { complete: "완료", current: "", upcoming: "시작 전", error: "오류" };

export function Stepper({ steps, current, orientation = "horizontal", onStepSelect, details = "all", compact = false, label = "진행 단계", completeLabel = "모든 단계를 마쳤어요", className }: StepperProps) {
  const reduced = useReducedMotion() ?? false;
  const count = steps.length;
  const active = Math.min(Math.max(Math.round(current), 0), count);
  // Remember where progress came from, so a jump across several steps fills or drains its connectors one after another.
  const [travel, setTravel] = useState({ to: active, from: active });
  if (travel.to !== active) setTravel({ to: active, from: travel.to });
  const from = travel.from;
  const gap = motionTokens.stagger.line;
  const delayAt = (index: number) => {
    if (reduced) return 0;
    if (active > from) return index >= from && index < active ? (index - from) * gap : 0;
    return index >= active && index < from ? (from - 1 - index) * gap : 0;
  };
  const ringDelay = reduced ? 0 : Math.max(0, Math.abs(active - from) - 1) * gap + .12;
  const interactive = Boolean(onStepSelect);
  const vertical = orientation === "vertical";
  const done = active >= count;
  const now = done ? undefined : steps[active];

  /** Arrow keys move between the steps you can reach; Home and End jump to the first and the current one. */
  function onKeyDown(event: KeyboardEvent<HTMLOListElement>) {
    const rtl = !vertical && getComputedStyle(event.currentTarget).direction === "rtl";
    const back = ["ArrowUp", rtl ? "ArrowRight" : "ArrowLeft"], ahead = ["ArrowDown", rtl ? "ArrowLeft" : "ArrowRight"];
    if (![...back, ...ahead, "Home", "End"].includes(event.key)) return;
    const targets = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("button[data-reachable]"));
    const at = targets.indexOf(event.target as HTMLButtonElement);
    if (at < 0) return;
    event.preventDefault();
    const next = event.key === "Home" ? 0 : event.key === "End" ? targets.length - 1 : back.includes(event.key) ? Math.max(0, at - 1) : Math.min(targets.length - 1, at + 1);
    targets[next]?.focus();
  }

  const Root = interactive ? "nav" : "div";
  const classes = [styles.root, vertical ? styles.vertical : styles.horizontal, compact ? styles.compact : "", className].filter(Boolean).join(" ");
  return <Root className={classes} aria-label={label} role={interactive ? undefined : "group"}>
    <ol className={styles.list} style={vertical ? undefined : { gridTemplateColumns: count > 1 ? `repeat(${count - 1}, minmax(0, 1fr)) auto` : "auto" }} onKeyDown={interactive ? onKeyDown : undefined}>
      {steps.map((step, index) => {
        const isCurrent = index === active;
        const status: StepperStatus = step.error ? "error" : index < active ? "complete" : isCurrent ? "current" : "upcoming";
        const clickable = interactive && index < active;
        const detail = step.error ?? (details === "all" || isCurrent ? step.description : undefined);
        const kind: GlyphKind = step.error ? "error" : index < active ? "check" : "number";
        const content: ReactNode = <>
          <Marker number={index + 1} kind={kind} current={isCurrent} glyphDelay={delayAt(index)} ringDelay={ringDelay} reduced={reduced} />
          <span className={styles.text}>
            <span className={styles.label}>{step.label}</span>
            {statusText[status] ? <span className={styles.srOnly}>, {statusText[status]}</span> : null}
            <SwapText text={detail} className={step.error ? styles.error : styles.description} reduced={reduced} />
          </span>
        </>;
        return <li key={step.id} className={styles.item} data-status={status}>
          {index < count - 1 ? <span className={styles.connector} aria-hidden="true">
            <motion.span key={orientation} className={styles.fill} initial={false} animate={vertical ? { scaleY: index < active ? 1 : 0 } : { scaleX: index < active ? 1 : 0 }} transition={reduced ? still : { ...motionTokens.spring.smooth, delay: delayAt(index) }} />
          </span> : null}
          {interactive
            ? <button type="button" className={styles.head} data-clickable={clickable || undefined} data-reachable={index <= active || undefined} aria-current={isCurrent ? "step" : undefined} aria-disabled={clickable ? undefined : true} tabIndex={clickable ? undefined : -1} onClick={clickable ? () => { playUiSound("tick"); onStepSelect?.(index); } : undefined}>{content}</button>
            : <span className={styles.head} aria-current={isCurrent ? "step" : undefined}>{content}</span>}
        </li>;
      })}
    </ol>
    {vertical ? null : <span className={styles.caption} aria-hidden="true">
      <SwapText text={now?.label ?? completeLabel} className={styles.captionLabel} reduced={reduced} />
      <SwapText text={now?.error ?? now?.description} className={now?.error ? styles.error : styles.description} reduced={reduced} />
    </span>}
    <span className={styles.srOnly} aria-live="polite">{now ? `${count}단계 중 ${active + 1}단계: ${now.label}` : completeLabel}</span>
  </Root>;
}

export default Stepper;