{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"progress-stepper","type":"registry:component","title":"Progress stepper","description":"Fills the line toward the next step and unfills it in reverse.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/progress-stepper.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// One connector fills in 240ms: long enough to read as travel from one step\n// to the next, short enough to stay under the 300ms UI budget.\nconst LINE_MS = 240;\n// A jump across several steps runs the connectors as a relay, each starting\n// halfway through the previous one, so a full reset still ends in ~480ms.\nconst STAGGER_MS = 120;\n// The step a line is heading for lights up just before the fill reaches it,\n// so the circle reads as the line's destination rather than a separate event.\nconst ARRIVE_MS = 160;\n// The \"you are here\" halo is one capsule that inches along the track: the\n// head leaves with the line fill and lands as it does, the tail lets go this\n// much later and catches up, so the halo stretches over the connector and\n// then pulls itself in around the new step. Longer than the 300ms budget in\n// total (up to ~600ms on a full reset) because it is the travel the fill\n// already takes, with the tail's short lag on the end.\nconst TAIL_LAG = 0.12;\nconst INCH_EASE = [0.77, 0, 0.175, 1] as const;\n// 36px circle plus a 4px halo on each side.\nconst HALO = 22;\n\ntype Status = \"complete\" | \"current\" | \"upcoming\";\n\nexport function ProgressStepper({\n  steps,\n  current,\n  label = \"Progress\",\n  className,\n}: {\n  steps: string[];\n  current: number;\n  label?: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  // Remembers where the last move started, so delays run in the direction of\n  // travel: forward fills left to right, back unfills right to left.\n  const [move, setMove] = useState({ from: current, to: current });\n  if (move.to !== current) setMove({ from: move.to, to: current });\n  const { from } = move;\n  const last = steps.length - 1;\n  const n = steps.length;\n\n  const olRef = useRef<HTMLOListElement>(null);\n  const rtl = useRef(false);\n  const head = useMotionValue(current);\n  const tail = useMotionValue(current);\n  const runs = useRef<AnimationPlaybackControls[]>([]);\n  const prev = useRef(current);\n\n  useEffect(() => {\n    const start = prev.current;\n    prev.current = current;\n    if (start === current) return;\n    if (olRef.current) rtl.current = getComputedStyle(olRef.current).direction === \"rtl\";\n    runs.current.forEach((r) => r.stop());\n    if (reduceMotion) {\n      head.jump(current);\n      tail.jump(current);\n      return;\n    }\n    // Matches the relay: the last connector finishes filling at this point.\n    const travel = ((Math.abs(current - start) - 1) * STAGGER_MS + LINE_MS) / 1000;\n    runs.current = [\n      animate(head, current, { duration: travel, ease: INCH_EASE }),\n      animate(tail, current, { duration: travel, ease: INCH_EASE, delay: TAIL_LAG }),\n    ];\n  }, [current, reduceMotion, head, tail]);\n\n  useEffect(() => () => runs.current.forEach((r) => r.stop()), []);\n\n  // One strip the width of the list, clipped down to a capsule between the\n  // centres of the two ends. clip-path keeps it off layout, and percentages\n  // follow the grid's equal columns at any width.\n  const clip = useTransform([head, tail], ([h, t]: number[]) => {\n    const lo = ((Math.min(h, t) + 0.5) / n) * 100;\n    const hi = ((Math.max(h, t) + 0.5) / n) * 100;\n    const [l, r] = rtl.current ? [100 - hi, lo] : [lo, 100 - hi];\n    return `inset(0 calc(${r}% - ${HALO}px) 0 calc(${l}% - ${HALO}px) round ${HALO}px)`;\n  });\n\n  const lineDelay = (k: number) => {\n    if (reduceMotion) return 0;\n    if (current > from && k >= from && k < current) return (k - from) * STAGGER_MS;\n    if (current < from && k >= current && k < from)\n      return (from - 1 - k) * STAGGER_MS;\n    return 0;\n  };\n\n  const stepDelay = (j: number) => {\n    if (reduceMotion) return 0;\n    if (current > from && j > from && j <= current)\n      return (j - 1 - from) * STAGGER_MS + ARRIVE_MS;\n    if (current < from && j >= current && j < from)\n      return (from - 1 - j) * STAGGER_MS + ARRIVE_MS;\n    return 0;\n  };\n\n  const statusOf = (j: number): Status =>\n    j < current ? \"complete\" : j === current ? \"current\" : \"upcoming\";\n\n  return (\n    <div className={cn(\"w-[min(520px,100%)]\", className)}>\n      <ol\n        ref={olRef}\n        aria-label={label}\n        className=\"relative grid\"\n        style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}\n      >\n        <motion.span\n          aria-hidden\n          style={{ clipPath: clip }}\n          // Sits under the circles and the connector, so at rest only a 4px\n          // ring shows and in flight the line runs through it like a tube.\n          className=\"pointer-events-none absolute inset-x-0 -top-1 h-11 bg-foreground/10\"\n        />\n        {steps.map((step, j) => {\n          const status = statusOf(j);\n          // The last step is a destination, not a task: reaching it completes it.\n          const checked =\n            status === \"complete\" || (status === \"current\" && j === last);\n          const delay = stepDelay(j);\n          return (\n            <li\n              key={step}\n              aria-current={status === \"current\" ? \"step\" : undefined}\n              className=\"relative flex flex-col items-center gap-2.5\"\n            >\n              {j < last && (\n                // Starts 8px clear of each 36px circle: 18px radius + 8px gap.\n                <span\n                  aria-hidden\n                  className=\"absolute top-[17px] right-[calc(-50%+26px)] left-[calc(50%+26px)] h-0.5 overflow-hidden rounded-full bg-border\"\n                >\n                  <span\n                    className={cn(\n                      \"absolute inset-0 origin-left rounded-full bg-foreground transition-[scale] ease-[cubic-bezier(0.77,0,0.175,1)] motion-reduce:transition-none rtl:origin-right\",\n                      j < current ? \"scale-x-100\" : \"scale-x-0\",\n                    )}\n                    style={{\n                      transitionDuration: `${LINE_MS}ms`,\n                      transitionDelay: `${lineDelay(j)}ms`,\n                    }}\n                  />\n                </span>\n              )}\n              <span\n                aria-hidden\n                className={cn(\n                  \"relative grid size-9 place-items-center rounded-full text-sm font-medium tabular-nums inset-ring transition-[background-color,color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n                  checked\n                    ? \"bg-foreground text-background inset-ring-foreground\"\n                    : status === \"current\"\n                      ? \"bg-background text-foreground inset-ring-foreground\"\n                      : \"bg-background text-muted-foreground inset-ring-border\",\n                )}\n                style={{ transitionDelay: `${delay}ms` }}\n              >\n                <Swap visible={!checked} delay={delay} reduceMotion={reduceMotion}>\n                  {j + 1}\n                </Swap>\n                <Swap visible={checked} delay={delay} reduceMotion={reduceMotion}>\n                  <svg\n                    viewBox=\"0 0 16 16\"\n                    className=\"size-4\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={2}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                  >\n                    <path d=\"m3.5 8.5 3 3 6-7\" />\n                  </svg>\n                </Swap>\n              </span>\n              <span\n                className={cn(\n                  \"text-center text-sm transition-[color] duration-200 ease-out\",\n                  status === \"upcoming\" ? \"text-muted-foreground\" : \"text-foreground\",\n                )}\n                style={{ transitionDelay: `${delay}ms` }}\n              >\n                {step}\n                <span className=\"sr-only\">\n                  {checked ? \", completed\" : status === \"upcoming\" ? \", not started\" : \"\"}\n                </span>\n              </span>\n            </li>\n          );\n        })}\n      </ol>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {current !== from\n          ? `Step ${current + 1} of ${steps.length}: ${steps[current]}`\n          : \"\"}\n      </span>\n    </div>\n  );\n}\n\nfunction Swap({\n  visible,\n  delay,\n  reduceMotion,\n  children,\n}: {\n  visible: boolean;\n  delay: number;\n  reduceMotion: boolean | null;\n  children: React.ReactNode;\n}) {\n  // Reduced motion keeps the cross-fade but drops the scale and blur.\n  const hidden = reduceMotion\n    ? { opacity: 0 }\n    : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" };\n  return (\n    <motion.span\n      className=\"col-start-1 row-start-1 grid place-items-center\"\n      initial={false}\n      animate={visible ? { scale: 1, opacity: 1, filter: \"blur(0px)\" } : hidden}\n      // Both glyphs wait with the circle's fill, so a circle is never left\n      // blank while its color is still catching up.\n      transition={{ ...ICON_SWAP, delay: delay / 1000 }}\n    >\n      {children}\n    </motion.span>\n  );\n}\n\nconst button =\n  \"h-10 touch-manipulation rounded-full px-4 text-sm font-medium outline-hidden transition-[scale,opacity] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[opacity]\";\n\nexport default function ProgressStepperDemo() {\n  const steps = [\"Cart\", \"Shipping\", \"Payment\", \"Done\"];\n  const [current, setCurrent] = useState(0);\n  const last = steps.length - 1;\n  const atStart = current === 0;\n  const atEnd = current === last;\n\n  return (\n    <div className=\"flex w-[min(520px,100%)] flex-col gap-10\">\n      <ProgressStepper steps={steps} current={current} label=\"Checkout progress\" />\n      <div className=\"flex items-center justify-between\">\n        {/* aria-disabled rather than disabled, so a keyboard user who backs up\n            to the start keeps focus on the button instead of losing it. */}\n        <button\n          type=\"button\"\n          aria-disabled={atStart}\n          onClick={() => !atStart && setCurrent((c) => c - 1)}\n          className={cn(\n            button,\n            \"bg-muted text-foreground shadow-raised\",\n            atStart && \"cursor-not-allowed opacity-50 active:scale-100\",\n          )}\n        >\n          Back\n        </button>\n        <button\n          type=\"button\"\n          onClick={() => setCurrent((c) => (c === last ? 0 : c + 1))}\n          className={cn(button, \"bg-foreground text-background\")}\n        >\n          {/* Both labels share one cell, so the button keeps one width and\n              never shifts under the cursor when it turns into Start over. */}\n          <span className=\"grid\">\n            <Label visible={!atEnd}>Continue</Label>\n            <Label visible={atEnd}>Start over</Label>\n          </span>\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction Label({ visible, children }: { visible: boolean; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!visible}\n      className={cn(\n        \"col-start-1 row-start-1 transition-[opacity,filter] duration-200 ease-out\",\n        !visible && \"opacity-0 blur-[4px]\",\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["feedback"],"docs":"From ui lab: https://lab.xevrion.dev/lab/progress-stepper"}