{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stepper",
  "type": "registry:ui",
  "title": "Stepper",
  "description": "Show where a person is in a multi-step flow and what is done.",
  "categories": [
    "feedback"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/stepper/stepper.tsx",
      "type": "registry:component",
      "target": "@components/xzero/stepper/stepper.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./stepper.module.css\";\n\nexport type StepperOrientation = \"horizontal\" | \"vertical\";\nexport type StepperStatus = \"complete\" | \"current\" | \"upcoming\" | \"error\";\n\nexport interface StepperStep {\n  /** Stable key, so each step keeps its own motion when the list changes. */\n  id: string;\n  label: string;\n  /** A short hint under the label. */\n  description?: string;\n  /** Marks the step as failed: the marker morphs into an alert and this message replaces the description. */\n  error?: string;\n}\n\n/**\n * A steps indicator for onboarding, checkout, and setup flows. Place it above (horizontal) or beside (vertical) the step\n * content and drive it with `current`; set `current` to `steps.length` once every step is done. Pass `onStepSelect` to let\n * people return to completed steps by click or keyboard. For a multi-step form that owns its own content, use MultiStepForm.\n */\nexport interface StepperProps {\n  steps: StepperStep[];\n  /** Index of the step in progress. `steps.length` marks the whole flow complete. */\n  current: number;\n  orientation?: StepperOrientation;\n  /** Called with the index of a completed step when it is chosen. Without it the stepper is a read-only indicator. */\n  onStepSelect?: (index: number) => void;\n  /** `current` shows only the active step's description, for tight spaces. Errors always show. */\n  details?: \"all\" | \"current\";\n  /** Markers only; labels stay available to assistive tech. Horizontal steppers switch to this below 30rem on their own\n   *  and show the current step's label underneath. */\n  compact?: boolean;\n  /** Accessible name for the stepper. */\n  label?: string;\n  /** Announced, and shown in the compact caption, once every step is complete. */\n  completeLabel?: string;\n  className?: string;\n}\n\ntype GlyphKind = \"number\" | \"check\" | \"error\";\n\nconst blur = (radius: number) => `blur(${radius}px)`;\nconst still = { duration: 0 } as const;\nconst leave = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } as const;\nconst settle = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } as const;\nconst textFrom = { opacity: 0, y: \"0.3em\", filter: blur(motionTokens.blur.soft) };\nconst textRest = { opacity: 1, y: 0, filter: blur(0) };\nconst textGone = { opacity: 0, y: \"-0.3em\", filter: blur(motionTokens.blur.subtle) };\nconst glyphFrom = { opacity: 0, scale: .5, filter: blur(motionTokens.blur.subtle) };\nconst glyphRest = { opacity: 1, scale: 1, filter: blur(0) };\n\n/** Text that rises in with a small blur when it changes. Its slot springs to the new height, so a message that wraps\n *  eases the steps below it down instead of pushing them; unrelated resizes (fonts, container width) follow exactly. */\nfunction SwapText({ text, className, reduced }: { text?: string; className?: string; reduced: boolean }) {\n  const inner = useRef<HTMLSpanElement>(null);\n  const armedUntil = useRef(0);\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  useLayoutEffect(() => { armedUntil.current = performance.now() + 700; }, [text]);\n  useEffect(() => {\n    const node = inner.current, slot = node?.parentElement;\n    if (!node || !slot || typeof ResizeObserver === \"undefined\") return;\n    let measured = false;\n    const observer = new ResizeObserver(() => {\n      const next = node.offsetHeight;\n      if (!measured || reduced || performance.now() > armedUntil.current) { measured = true; height.jump(next); return; }\n      animate(height, next, motionTokens.spring.smooth);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [height, reduced]);\n  return <motion.span className={styles.slot} style={{ height }}>\n    <span ref={inner} className={styles.slotInner}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {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}\n      </AnimatePresence>\n    </span>\n  </motion.span>;\n}\n\n/** The number, check, and alert share one spot: the outgoing glyph shrinks away while the next one pops in and draws its stroke. */\nfunction Glyph({ kind, number, delay, reduced }: { kind: GlyphKind; number: number; delay: number; reduced: boolean }) {\n  const pop = reduced ? still : { scale: { ...motionTokens.spring.snappy, delay }, opacity: { duration: motionTokens.duration.fast, delay }, filter: { duration: motionTokens.duration.fast, delay } };\n  const exit = { ...glyphFrom, transition: reduced ? still : leave };\n  const draw = reduced ? still : { duration: motionTokens.duration.standard, ease: motionTokens.ease.enter, delay: delay + .04 };\n  if (kind === \"number\") return <motion.span className={styles.glyph} initial={glyphFrom} animate={glyphRest} exit={exit} transition={pop}>{number}</motion.span>;\n  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}>\n    {kind === \"check\"\n      ? <motion.path d=\"M5.5 12.5l4.25 4.25L18.5 8\" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={draw} />\n      : <>\n        <motion.path d=\"M12 6.75v6.5\" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={draw} />\n        <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 }} />\n      </>}\n  </motion.svg>;\n}\n\n/** The ring grows out from behind the disc once progress arrives, so the eye lands on the new step after the connector fills. */\nfunction Marker({ number, kind, current, glyphDelay, ringDelay, reduced }: { number: number; kind: GlyphKind; current: boolean; glyphDelay: number; ringDelay: number; reduced: boolean }) {\n  return <span className={styles.marker} aria-hidden=\"true\">\n    <AnimatePresence initial={false}>\n      {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 }}\n        transition={reduced ? still : { scale: { ...motionTokens.spring.snappy, delay: ringDelay }, opacity: { duration: motionTokens.duration.fast, delay: ringDelay } }} /> : null}\n    </AnimatePresence>\n    <span className={styles.disc}>\n      <AnimatePresence initial={false}><Glyph key={kind} kind={kind} number={number} delay={glyphDelay} reduced={reduced} /></AnimatePresence>\n    </span>\n  </span>;\n}\n\nconst statusText: Record<StepperStatus, string> = { complete: \"Completed\", current: \"\", upcoming: \"Not started\", error: \"Error\" };\n\nexport function Stepper({ steps, current, orientation = \"horizontal\", onStepSelect, details = \"all\", compact = false, label = \"Progress\", completeLabel = \"All steps complete\", className }: StepperProps) {\n  const reduced = useReducedMotion() ?? false;\n  const count = steps.length;\n  const active = Math.min(Math.max(Math.round(current), 0), count);\n  // Remember where progress came from, so a jump across several steps fills or drains its connectors one after another.\n  const [travel, setTravel] = useState({ to: active, from: active });\n  if (travel.to !== active) setTravel({ to: active, from: travel.to });\n  const from = travel.from;\n  const gap = motionTokens.stagger.line;\n  const delayAt = (index: number) => {\n    if (reduced) return 0;\n    if (active > from) return index >= from && index < active ? (index - from) * gap : 0;\n    return index >= active && index < from ? (from - 1 - index) * gap : 0;\n  };\n  const ringDelay = reduced ? 0 : Math.max(0, Math.abs(active - from) - 1) * gap + .12;\n  const interactive = Boolean(onStepSelect);\n  const vertical = orientation === \"vertical\";\n  const done = active >= count;\n  const now = done ? undefined : steps[active];\n\n  /** Arrow keys move between the steps you can reach; Home and End jump to the first and the current one. */\n  function onKeyDown(event: KeyboardEvent<HTMLOListElement>) {\n    const rtl = !vertical && getComputedStyle(event.currentTarget).direction === \"rtl\";\n    const back = [\"ArrowUp\", rtl ? \"ArrowRight\" : \"ArrowLeft\"], ahead = [\"ArrowDown\", rtl ? \"ArrowLeft\" : \"ArrowRight\"];\n    if (![...back, ...ahead, \"Home\", \"End\"].includes(event.key)) return;\n    const targets = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>(\"button[data-reachable]\"));\n    const at = targets.indexOf(event.target as HTMLButtonElement);\n    if (at < 0) return;\n    event.preventDefault();\n    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);\n    targets[next]?.focus();\n  }\n\n  const Root = interactive ? \"nav\" : \"div\";\n  const classes = [styles.root, vertical ? styles.vertical : styles.horizontal, compact ? styles.compact : \"\", className].filter(Boolean).join(\" \");\n  return <Root className={classes} aria-label={label} role={interactive ? undefined : \"group\"}>\n    <ol className={styles.list} style={vertical ? undefined : { gridTemplateColumns: count > 1 ? `repeat(${count - 1}, minmax(0, 1fr)) auto` : \"auto\" }} onKeyDown={interactive ? onKeyDown : undefined}>\n      {steps.map((step, index) => {\n        const isCurrent = index === active;\n        const status: StepperStatus = step.error ? \"error\" : index < active ? \"complete\" : isCurrent ? \"current\" : \"upcoming\";\n        const clickable = interactive && index < active;\n        const detail = step.error ?? (details === \"all\" || isCurrent ? step.description : undefined);\n        const kind: GlyphKind = step.error ? \"error\" : index < active ? \"check\" : \"number\";\n        const content: ReactNode = <>\n          <Marker number={index + 1} kind={kind} current={isCurrent} glyphDelay={delayAt(index)} ringDelay={ringDelay} reduced={reduced} />\n          <span className={styles.text}>\n            <span className={styles.label}>{step.label}</span>\n            {statusText[status] ? <span className={styles.srOnly}>, {statusText[status]}</span> : null}\n            <SwapText text={detail} className={step.error ? styles.error : styles.description} reduced={reduced} />\n          </span>\n        </>;\n        return <li key={step.id} className={styles.item} data-status={status}>\n          {index < count - 1 ? <span className={styles.connector} aria-hidden=\"true\">\n            <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) }} />\n          </span> : null}\n          {interactive\n            ? <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 ? () => onStepSelect?.(index) : undefined}>{content}</button>\n            : <span className={styles.head} aria-current={isCurrent ? \"step\" : undefined}>{content}</span>}\n        </li>;\n      })}\n    </ol>\n    {vertical ? null : <span className={styles.caption} aria-hidden=\"true\">\n      <SwapText text={now?.label ?? completeLabel} className={styles.captionLabel} reduced={reduced} />\n      <SwapText text={now?.error ?? now?.description} className={now?.error ? styles.error : styles.description} reduced={reduced} />\n    </span>}\n    <span className={styles.srOnly} aria-live=\"polite\">{now ? `Step ${active + 1} of ${count}: ${now.label}` : completeLabel}</span>\n  </Root>;\n}\n\nexport default Stepper;\n"
    },
    {
      "path": "registry/components/stepper/stepper.module.css",
      "type": "registry:component",
      "target": "@components/xzero/stepper/stepper.module.css",
      "content": ".root { --marker: 1.75rem; --halo: 4px; min-width: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n/* A horizontal stepper spans its container, so it can safely measure itself and fold into markers when space runs out. */\n.horizontal { width: 100%; container: stepper / inline-size; }\n.list { display: grid; margin: 0; padding: 0; list-style: none; }\n.item { position: relative; min-width: 0; }\n.horizontal .item:not(:last-child) { padding-inline-end: var(--space-4); }\n.vertical .item:not(:last-child) { padding-bottom: var(--space-5); }\n.compact.vertical .item:not(:last-child) { padding-bottom: var(--space-6); }\n\n/* Buttons and spans share one head, so a chosen step keeps focus as it becomes the current one. */\n.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; }\n.head[data-clickable] { cursor: pointer; }\n.horizontal .head { width: fit-content; max-width: 100%; justify-items: start; row-gap: var(--space-3); }\n.vertical .head { width: 100%; grid-template-columns: var(--marker) minmax(0, 1fr); column-gap: var(--space-3); align-items: start; }\n.compact.vertical .head { grid-template-columns: var(--marker); column-gap: 0; }\n.head:focus-visible { --focus-outline-offset: 4px; }\n.head:focus:not(:focus-visible) { outline: none; }\n\n/* The ring sits behind the disc and grows out of it; the disc carries the state color and the glyph. */\n.marker { position: relative; display: grid; width: var(--marker); height: var(--marker); flex: none; place-items: center; }\n.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); }\n.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); }\n.glyph { display: grid; grid-area: 1 / 1; place-items: center; }\n.icon { width: .875rem; height: .875rem; }\n.item[data-status=\"current\"] .disc { box-shadow: inset 0 0 0 1.5px var(--accent); color: var(--foreground); }\n.item[data-status=\"complete\"] .disc { background: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); color: var(--accent-foreground); }\n.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); }\n.item[data-status=\"error\"] .ring { background: color-mix(in oklab, var(--danger) 16%, transparent); }\n\n/* The track stays put; only the fill scales along it on a spring, from the step that was just finished. */\n.connector { position: absolute; overflow: hidden; border-radius: var(--radius-pill); background: var(--border); pointer-events: none; }\n.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; }\n/* Both ends keep a clear gap from the markers, and from the current step's ring. */\n.vertical .connector { top: calc(var(--marker) + var(--halo) + 2px); bottom: calc(var(--halo) + 2px); inset-inline-start: calc(var(--marker) / 2 - 1px); width: 2px; }\n.fill { position: absolute; inset: 0; border-radius: inherit; background: var(--accent); }\n.horizontal .fill { transform-origin: left center; }\n.horizontal .fill:dir(rtl) { transform-origin: right center; }\n.vertical .fill { transform-origin: center top; }\n\n.text { display: grid; min-width: 0; }\n/* Centers the first line of the label on the marker. */\n.vertical .text { padding-top: .25rem; }\n.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); }\n.item[data-status=\"complete\"] .label { color: var(--text-secondary); }\n.item[data-status=\"current\"] .label, .item[data-status=\"error\"] .label { color: var(--foreground); }\n/* Changing text pops out of flow against this box while it leaves, so it must be the positioned parent. */\n.slot { display: block; }\n.slotInner { position: relative; display: block; }\n.description, .error { display: block; padding-top: 2px; font-size: var(--text-xs); font-weight: 400; line-height: var(--leading-body); overflow-wrap: break-word; }\n.description { color: var(--text-muted); }\n.error { color: var(--danger); }\n\n.caption { display: none; margin-top: var(--space-3); }\n.captionLabel { display: block; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: 1.25rem; }\n\n@media (hover: hover) and (pointer: fine) {\n  .head[data-clickable]:hover .label { color: var(--foreground); }\n  .item[data-status=\"complete\"] .head[data-clickable]:hover .disc { background: var(--accent-strong); box-shadow: inset 0 0 0 1px var(--accent-strong); }\n  .item[data-status=\"error\"] .head[data-clickable]:hover .disc { background: color-mix(in oklab, var(--danger) 18%, var(--surface)); }\n}\n.head[data-clickable]:active .disc { transform: scale(.92); }\n\n.srOnly, .compact .text { position: absolute; width: 1px; height: 1px; margin: 0; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n.compact.horizontal .caption { display: grid; }\n/* Four labels no longer fit side by side: keep the markers and name the current step underneath. */\n@container stepper (max-width: 30rem) {\n  .horizontal .text { position: absolute; width: 1px; height: 1px; margin: 0; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n  .horizontal .caption { display: grid; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .disc, .ring, .label { transition: none; }\n  .head[data-clickable]:active .disc { transform: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/stepper",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "motion",
      "product"
    ],
    "docs": "https://ui.xzero.co.uk/components/stepper",
    "markdown": "https://ui.xzero.co.uk/components/stepper/markdown"
  }
}