{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stepper",
  "type": "registry:ui",
  "title": "Stepper",
  "description": "An accessible React stepper for Next.js and Tailwind CSS: the steps of a checkout or setup, with a line that fills as you advance and checks that draw in.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "motion@^14.0.0",
    "radix-ui@^1.7.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/stepper.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { blur, duration, ease } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { Slot } from \"radix-ui\";\nimport {\n  type ComponentProps,\n  createContext,\n  type MouseEvent,\n  useCallback,\n  useContext,\n  useEffect,\n  useMemo,\n  useState,\n} from \"react\";\n\n/*\n * Stepper: progress through the steps of a task (owner's roadmap, 2026-10-16), such as a checkout or a setup.\n * shadcn has none, so the parts are ours, named the shadcn way: Stepper (an ordered list), StepperItem,\n * StepperTrigger, StepperIndicator, StepperTitle, StepperDescription and StepperSeparator.\n * - State: `value` is the current step, from 1; the steps before it are completed, and a value past the last\n *   step marks them all done. Controlled or not (value / defaultValue / onValueChange), horizontal or vertical.\n * - Motion (the crafted part, .agents/rules/motion.md): progress travels along the line. Moving on, the step you\n *   leave swaps its number for a check that draws itself in (Checkbox's check), the line to the next step fills\n *   (scaleX, or scaleY when vertical, on an eased tween: duration.slow, ease-in-out, never a spring), and the next\n *   step takes the ring as the fill arrives. Jumping several steps fills each line in turn. Going back plays it\n *   backwards: the ring leaves, the lines empty towards the step you picked, and as the emptying reaches it, its\n *   check erases and its number comes back through the blur.\n * - Fills and colours only fade; the number comes and goes through the blur; the ring grows into place as it\n *   fades in. Nothing moves on first paint, and with reduced motion it all changes at once.\n * - Keyboard and screen readers: each step is a button (or, with asChild, a link) in the tab order, named by its\n *   title; the current one has aria-current=\"step\". It's an `ol`, so it's read as a list of so many steps.\n */\n\ntype StepState = \"completed\" | \"active\" | \"inactive\";\ntype Orientation = \"horizontal\" | \"vertical\";\n\n/** One line's fill. Each step along the way waits for the line before it. */\nconst LEG = duration.slow;\n\ntype StepperContextValue = {\n  value: number;\n  /** The step the last change came from, while its progress is still travelling; then the same as value */\n  from: number;\n  orientation: Orientation;\n  reduce: boolean;\n  setValue: (step: number) => void;\n};\nconst StepperContext = createContext<StepperContextValue | null>(null);\n\nfunction useStepper(part: string) {\n  const context = useContext(StepperContext);\n  if (!context) throw new Error(`${part} must be used inside Stepper`);\n  return context;\n}\n\ntype StepperItemContextValue = {\n  step: number;\n  state: StepState;\n  disabled: boolean;\n  /** Seconds until this step's indicator changes: when the travelling progress reaches it */\n  delay: number;\n  /** Seconds until the line after this step starts filling or emptying */\n  lineDelay: number;\n};\nconst StepperItemContext = createContext<StepperItemContextValue | null>(null);\n\nfunction useStepperItem(part: string) {\n  const context = useContext(StepperItemContext);\n  if (!context) throw new Error(`${part} must be used inside StepperItem`);\n  return context;\n}\n\n/** A number of legs, in seconds (to the millisecond, so 3 × 0.35 is 1.05) */\nconst legs = (count: number) => Math.round(count * LEG * 1000) / 1000;\n\n/** When a step changes and when the line after it moves, as progress travels from `from` to `to` */\nfunction timingOf(step: number, from: number, to: number) {\n  if (to > from) {\n    return {\n      delay: step >= from && step <= to ? legs(step - from) : 0,\n      lineDelay: step >= from && step < to ? legs(step - from) : 0,\n    };\n  }\n  if (to < from) {\n    return {\n      delay: step >= to && step <= from ? legs(from - step) : 0,\n      lineDelay: step >= to && step < from ? legs(from - 1 - step) : 0,\n    };\n  }\n  return { delay: 0, lineDelay: 0 };\n}\n\nexport type StepperProps = Omit<ComponentProps<\"ol\">, \"defaultValue\" | \"onChange\"> & {\n  /** The current step, from 1, when you control it. Past the last step, every step is done */\n  value?: number;\n  /** The current step at first */\n  defaultValue?: number;\n  /** Called with the step a trigger was pressed for */\n  onValueChange?: (value: number) => void;\n  orientation?: Orientation;\n};\n\n/** The steps of a task, in order. Give it a name with aria-label (\"Checkout\") */\nfunction Stepper({\n  value,\n  defaultValue = 1,\n  onValueChange,\n  orientation = \"horizontal\",\n  className,\n  ...props\n}: StepperProps) {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue);\n  const current = value ?? uncontrolled;\n  const reduce = useReducedMotion() ?? false;\n  // Where the last change came from, so progress can travel from there. Once it has arrived, `from` catches up\n  // and every delay is 0 again, so hovering a step never waits.\n  const [shown, setShown] = useState(current);\n  const [from, setFrom] = useState(current);\n  if (current !== shown) {\n    setShown(current);\n    setFrom(shown);\n  }\n  useEffect(() => {\n    if (from === current) return;\n    const timer = setTimeout(() => setFrom(current), (Math.abs(current - from) + 1) * LEG * 1000);\n    return () => clearTimeout(timer);\n  }, [from, current]);\n\n  const setValue = useCallback(\n    (step: number) => {\n      if (value === undefined) setUncontrolled(step);\n      onValueChange?.(step);\n    },\n    [value, onValueChange],\n  );\n  const context = useMemo(\n    () => ({ value: current, from: reduce ? current : from, orientation, reduce, setValue }),\n    [current, from, orientation, reduce, setValue],\n  );\n\n  return (\n    <StepperContext.Provider value={context}>\n      <ol\n        data-slot=\"stepper\"\n        data-orientation={orientation}\n        className={cn(\n          \"flex\",\n          orientation === \"horizontal\" ? \"w-full items-start gap-3\" : \"flex-col\",\n          className,\n        )}\n        {...props}\n      />\n    </StepperContext.Provider>\n  );\n}\n\nexport type StepperItemProps = ComponentProps<\"li\"> & {\n  /** Its place in the task, from 1 */\n  step: number;\n  /** Done (true) or not (false), whatever the current step. Left out, the steps before the current one are done */\n  completed?: boolean;\n  /** Its trigger can't be pressed, for example a step that isn't reachable yet */\n  disabled?: boolean;\n};\n\n/** One step. Holds its trigger and the separator to the next step */\nfunction StepperItem({ step, completed, disabled = false, className, ...props }: StepperItemProps) {\n  const { value, from, orientation } = useStepper(\"StepperItem\");\n  const state: StepState = (completed ?? step < value) ? \"completed\" : step === value ? \"active\" : \"inactive\";\n  const { delay, lineDelay } = timingOf(step, from, value);\n  const item = useMemo(\n    () => ({ step, state, disabled, delay, lineDelay }),\n    [step, state, disabled, delay, lineDelay],\n  );\n\n  return (\n    <StepperItemContext.Provider value={item}>\n      <li\n        data-slot=\"stepper-item\"\n        data-state={state}\n        data-disabled={disabled || undefined}\n        className={cn(\n          \"group/step\",\n          // Horizontal: the trigger, then the line, which takes the room up to the next step\n          orientation === \"horizontal\" ? \"flex items-start gap-3 not-last:flex-1\" : \"relative not-last:pb-6\",\n          className,\n        )}\n        {...props}\n      />\n    </StepperItemContext.Provider>\n  );\n}\n\nexport type StepperTriggerProps = ComponentProps<\"button\"> & {\n  /** Render the single child instead of a button, for example a link to the step's page */\n  asChild?: boolean;\n};\n\n/**\n * Goes to its step. Holds the indicator, the title and the description, laid out in two columns. The current\n * step's trigger has aria-current=\"step\".\n */\nfunction StepperTrigger({ className, asChild = false, onClick, style, ...props }: StepperTriggerProps) {\n  const { setValue, reduce } = useStepper(\"StepperTrigger\");\n  const { step, state, disabled, delay } = useStepperItem(\"StepperTrigger\");\n  const Comp = asChild ? Slot.Root : \"button\";\n\n  return (\n    <Comp\n      data-slot=\"stepper-trigger\"\n      data-state={state}\n      type={asChild ? undefined : \"button\"}\n      aria-current={state === \"active\" ? \"step\" : undefined}\n      disabled={asChild ? undefined : disabled}\n      aria-disabled={asChild && disabled ? true : undefined}\n      onClick={(event: MouseEvent<HTMLButtonElement>) => {\n        onClick?.(event);\n        if (event.defaultPrevented) return;\n        if (disabled) {\n          // A disabled link mustn't navigate either\n          event.preventDefault();\n          return;\n        }\n        setValue(step);\n      }}\n      className={cn(\n        // Two columns: the indicator, spanning both rows, then the title over the description\n        \"group/trigger grid cursor-pointer items-start justify-items-start gap-x-2 rounded-md text-start\",\n        \"transition-opacity duration-(--duration-base) ease-out\",\n        \"outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)\",\n        \"disabled:pointer-events-none disabled:opacity-(--opacity-disabled) aria-disabled:pointer-events-none aria-disabled:opacity-(--opacity-disabled)\",\n        className,\n      )}\n      // A step that becomes reachable brightens as the progress arrives\n      style={{ transitionDelay: `${reduce ? 0 : delay}s`, ...style }}\n      {...props}\n    />\n  );\n}\n\n/** The same check as Checkbox, Copy Button and Loading Button */\nconst CHECK = \"M5 12.5l4.5 4.5L19 7.5\";\n\nexport type StepperIndicatorProps = ComponentProps<\"span\">;\n\n/**\n * The step's circle: its number, a check once it's done, and a ring round the current one. Pass children (an\n * icon) to show them instead of the number and the check. Hidden from screen readers: the title names the step.\n */\nfunction StepperIndicator({ className, children, style, ...props }: StepperIndicatorProps) {\n  const { reduce } = useStepper(\"StepperIndicator\");\n  const { step, state, delay } = useStepperItem(\"StepperIndicator\");\n  const done = state === \"completed\";\n  const active = state === \"active\";\n  const wait = reduce ? 0 : delay;\n\n  return (\n    <span\n      aria-hidden\n      data-slot=\"stepper-indicator\"\n      data-state={state}\n      className={cn(\n        \"relative row-span-2 inline-grid size-6 shrink-0 place-items-center rounded-full text-xs font-medium tabular-nums\",\n        // The fill only fades (motion.md), when the progress reaches the step\n        \"bg-muted text-muted-foreground transition-colors duration-(--duration-base) ease-out\",\n        \"data-[state=active]:bg-primary data-[state=active]:text-primary-foreground\",\n        \"data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground\",\n        \"group-hover/trigger:data-[state=inactive]:text-foreground\",\n        \"[&_svg]:pointer-events-none [&>svg]:size-3.5\",\n        className,\n      )}\n      style={{ transitionDelay: `${wait}s`, ...style }}\n      {...props}\n    >\n      {/* The ring round the current step grows into place as it fades in */}\n      <motion.span\n        data-slot=\"stepper-ring\"\n        className=\"pointer-events-none absolute -inset-1 rounded-full border border-primary/30\"\n        initial={false}\n        animate={active ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.8 }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration: duration.base, ease: active ? ease.out : ease.in, delay: wait }\n        }\n      />\n      {children ?? (\n        <>\n          {/* The number lifts away through the blur as the check draws in, and comes back the same way */}\n          <motion.span\n            className=\"col-start-1 row-start-1\"\n            initial={false}\n            // The same hidden state with reduced motion (it's instant then, so the blur never shows), so the\n            // server's HTML always hydrates\n            animate={done ? { opacity: 0, filter: blur.hidden } : { opacity: 1, filter: blur.shown }}\n            transition={\n              reduce\n                ? { duration: 0 }\n                : {\n                    duration: duration.fast,\n                    ease: done ? ease.in : ease.out,\n                    delay: done ? wait : wait + duration.fast,\n                  }\n            }\n          >\n            {step}\n          </motion.span>\n          <svg viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden className=\"col-start-1 row-start-1\">\n            {/* Opacity goes with the length, so a round cap never leaves a dot behind */}\n            <motion.path\n              d={CHECK}\n              stroke=\"currentColor\"\n              strokeWidth=\"3\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={false}\n              animate={{ pathLength: done ? 1 : 0, opacity: done ? 1 : 0 }}\n              transition={\n                reduce\n                  ? { duration: 0 }\n                  : done\n                    ? {\n                        pathLength: {\n                          duration: duration.base,\n                          ease: ease.out,\n                          delay: wait + duration.instant,\n                        },\n                        opacity: { duration: 0, delay: wait + duration.instant },\n                      }\n                    : { duration: duration.fast, ease: ease.in, delay: wait }\n              }\n            />\n          </svg>\n        </>\n      )}\n    </span>\n  );\n}\n\n/** The step's name, beside the indicator. Muted until the progress reaches it */\nfunction StepperTitle({ className, style, ...props }: ComponentProps<\"span\">) {\n  const { reduce } = useStepper(\"StepperTitle\");\n  const { state, delay } = useStepperItem(\"StepperTitle\");\n  return (\n    <span\n      data-slot=\"stepper-title\"\n      data-state={state}\n      className={cn(\n        // As tall as the indicator, so the two are centred on one line\n        \"col-start-2 row-start-1 text-sm leading-6 font-medium text-muted-foreground\",\n        \"transition-colors duration-(--duration-base) ease-out group-hover/trigger:text-foreground\",\n        \"data-[state=active]:text-foreground data-[state=completed]:text-foreground\",\n        className,\n      )}\n      style={{ transitionDelay: `${reduce ? 0 : delay}s`, ...style }}\n      {...props}\n    />\n  );\n}\n\n/** A line under the title */\nfunction StepperDescription({ className, ...props }: ComponentProps<\"span\">) {\n  return (\n    <span\n      data-slot=\"stepper-description\"\n      className={cn(\"col-start-2 row-start-2 text-xs text-muted-foreground\", className)}\n      {...props}\n    />\n  );\n}\n\n/**\n * The line to the next step. It fills when this step is done, and empties the way it came. Hidden in the last\n * step, so it can be written in every item.\n */\nfunction StepperSeparator({ className, ...props }: ComponentProps<\"div\">) {\n  const { orientation, reduce } = useStepper(\"StepperSeparator\");\n  const { state, lineDelay } = useStepperItem(\"StepperSeparator\");\n  const filled = state === \"completed\";\n  const horizontal = orientation === \"horizontal\";\n\n  return (\n    <div\n      aria-hidden\n      data-slot=\"stepper-separator\"\n      data-state={filled ? \"filled\" : \"empty\"}\n      className={cn(\n        \"overflow-hidden rounded-full bg-border group-last/step:hidden\",\n        horizontal\n          ? // Centred on the indicators' middle line\n            \"relative mt-3 h-0.5 min-w-4 flex-1 -translate-y-1/2\"\n          : // Down the indicators' middle, with a 4px gap at each end\n            \"absolute start-3 top-7 bottom-1 w-0.5 -translate-x-1/2 rtl:translate-x-1/2\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        data-slot=\"stepper-separator-fill\"\n        className={cn(\n          \"absolute inset-0 rounded-full bg-primary transition-transform duration-(--duration-slow) ease-in-out\",\n          horizontal ? \"origin-left rtl:origin-right\" : \"origin-top\",\n          !filled && (horizontal ? \"scale-x-0\" : \"scale-y-0\"),\n        )}\n        style={{ transitionDelay: `${reduce ? 0 : lineDelay}s` }}\n      />\n    </div>\n  );\n}\n\nexport {\n  Stepper,\n  StepperDescription,\n  StepperIndicator,\n  StepperItem,\n  StepperSeparator,\n  StepperTitle,\n  StepperTrigger,\n};\n"
    }
  ],
  "categories": [
    "effects"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/stepper"
}
