{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/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 { cn } from \"../lib/utils\";\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: 0.5, filter: blur(motionTokens.blur.subtle) };\nconst glyphRest = { opacity: 1, scale: 1, filter: blur(0) };\nconst srOnly = \"absolute m-0 size-px overflow-hidden whitespace-nowrap p-0 [clip-path:inset(50%)]\";\n/** The same visually hidden box, applied once a horizontal stepper is narrower than 30rem. */\nconst srOnlyNarrow =\n  \"@max-[30rem]/stepper:absolute @max-[30rem]/stepper:m-0 @max-[30rem]/stepper:size-px @max-[30rem]/stepper:overflow-hidden @max-[30rem]/stepper:whitespace-nowrap @max-[30rem]/stepper:p-0 @max-[30rem]/stepper:[clip-path:inset(50%)]\";\nconst detailText = \"block pt-0.5 text-(length:--text-xs) leading-body font-normal [overflow-wrap:break-word]\";\nconst descriptionText = cn(detailText, \"text-ink-3\");\nconst errorText = cn(detailText, \"text-red\");\nconst discTone: Record<StepperStatus, string> = {\n  upcoming: \"bg-surface text-ink-3 shadow-[inset_0_0_0_1px_var(--line-strong)]\",\n  current: \"bg-surface text-ink shadow-[inset_0_0_0_1.5px_var(--accent)]\",\n  complete: \"bg-accent text-on-accent shadow-[inset_0_0_0_1px_var(--accent)]\",\n  error: \"bg-[color-mix(in_oklab,var(--red)_10%,var(--surface))] text-red shadow-[inset_0_0_0_1.5px_var(--red)]\",\n};\n/** Hover answers only on steps you can return to. */\nconst discHover: Partial<Record<StepperStatus, string>> = {\n  complete: \"group-hover/head:bg-accent-ink group-hover/head:shadow-[inset_0_0_0_1px_var(--accent-ink)]\",\n  error: \"group-hover/head:bg-[color-mix(in_oklab,var(--red)_18%,var(--surface))]\",\n};\nconst labelTone: Record<StepperStatus, string> = {\n  upcoming: \"text-ink-3\",\n  current: \"text-ink\",\n  complete: \"text-ink-2\",\n  error: \"text-ink\",\n};\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(() => {\n    armedUntil.current = performance.now() + 700;\n  }, [text]);\n  useEffect(() => {\n    const node = inner.current,\n      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) {\n        measured = true;\n        height.jump(next);\n        return;\n      }\n      animate(height, next, motionTokens.spring.smooth);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [height, reduced]);\n  return (\n    <motion.span className=\"block\" style={{ height }}>\n      <span ref={inner} className=\"relative block\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {text ? (\n            <motion.span\n              key={`${className}:${text}`}\n              className={className}\n              initial={textFrom}\n              animate={textRest}\n              exit={{ ...textGone, transition: reduced ? still : leave }}\n              transition={reduced ? still : settle}\n            >\n              {text}\n            </motion.span>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </motion.span>\n  );\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\n    ? still\n    : {\n        scale: { ...motionTokens.spring.snappy, delay },\n        opacity: { duration: motionTokens.duration.fast, delay },\n        filter: { duration: motionTokens.duration.fast, delay },\n      };\n  const exit = { ...glyphFrom, transition: reduced ? still : leave };\n  const draw = reduced\n    ? still\n    : { duration: motionTokens.duration.standard, ease: motionTokens.ease.enter, delay: delay + 0.04 };\n  if (kind === \"number\")\n    return (\n      <motion.span\n        className=\"grid place-items-center [grid-area:1/1]\"\n        initial={glyphFrom}\n        animate={glyphRest}\n        exit={exit}\n        transition={pop}\n      >\n        {number}\n      </motion.span>\n    );\n  return (\n    <motion.svg\n      className=\"grid size-3.5 place-items-center [grid-area:1/1]\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      initial={glyphFrom}\n      animate={glyphRest}\n      exit={exit}\n      transition={pop}\n    >\n      {kind === \"check\" ? (\n        <motion.path\n          d=\"M5.5 12.5l4.25 4.25L18.5 8\"\n          initial={{ pathLength: 0 }}\n          animate={{ pathLength: 1 }}\n          transition={draw}\n        />\n      ) : (\n        <>\n          <motion.path d=\"M12 6.75v6.5\" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={draw} />\n          <motion.circle\n            cx={12}\n            cy={17.4}\n            r={1.4}\n            fill=\"currentColor\"\n            stroke=\"none\"\n            initial={{ scale: 0 }}\n            animate={{ scale: 1 }}\n            transition={reduced ? still : { ...motionTokens.spring.snappy, delay: delay + 0.16 }}\n          />\n        </>\n      )}\n    </motion.svg>\n  );\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({\n  number,\n  kind,\n  status,\n  current,\n  clickable,\n  glyphDelay,\n  ringDelay,\n  reduced,\n}: {\n  number: number;\n  kind: GlyphKind;\n  status: StepperStatus;\n  current: boolean;\n  clickable: boolean;\n  glyphDelay: number;\n  ringDelay: number;\n  reduced: boolean;\n}) {\n  return (\n    <span className=\"relative grid size-(--marker) flex-none place-items-center\" aria-hidden=\"true\">\n      <AnimatePresence initial={false}>\n        {current ? (\n          <motion.span\n            key=\"ring\"\n            className={cn(\n              \"absolute -inset-(--halo) rounded-full transition-[background-color] duration-standard ease-standard motion-reduce:transition-none\",\n              status === \"error\" ? \"bg-[color-mix(in_oklab,var(--red)_16%,transparent)]\" : \"bg-accent-tint\",\n            )}\n            initial={{ opacity: 0, scale: 0.6 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: 0.6, transition: reduced ? still : leave }}\n            transition={\n              reduced\n                ? still\n                : {\n                    scale: { ...motionTokens.spring.snappy, delay: ringDelay },\n                    opacity: { duration: motionTokens.duration.fast, delay: ringDelay },\n                  }\n            }\n          />\n        ) : null}\n      </AnimatePresence>\n      <span\n        className={cn(\n          \"relative grid size-full place-items-center rounded-full text-(length:--text-xs) leading-none font-medium tabular-nums transition-[background-color,box-shadow,color,scale] duration-[var(--duration-standard),var(--duration-standard),var(--duration-standard),var(--duration-spring)] ease-[var(--ease-standard),var(--ease-standard),var(--ease-standard),var(--ease-spring)] motion-reduce:transition-none\",\n          discTone[status],\n          clickable && cn(discHover[status], \"group-active/head:scale-92 motion-reduce:group-active/head:scale-100\"),\n        )}\n      >\n        <AnimatePresence initial={false}>\n          <Glyph key={kind} kind={kind} number={number} delay={glyphDelay} reduced={reduced} />\n        </AnimatePresence>\n      </span>\n    </span>\n  );\n}\n\nconst statusText: Record<StepperStatus, string> = {\n  complete: \"Completed\",\n  current: \"\",\n  upcoming: \"Not started\",\n  error: \"Error\",\n};\n\nexport function Stepper({\n  steps,\n  current,\n  orientation = \"horizontal\",\n  onStepSelect,\n  details = \"all\",\n  compact = false,\n  label = \"Progress\",\n  completeLabel = \"All steps complete\",\n  className,\n}: 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 + 0.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\"],\n      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 =\n      event.key === \"Home\"\n        ? 0\n        : event.key === \"End\"\n          ? targets.length - 1\n          : back.includes(event.key)\n            ? Math.max(0, at - 1)\n            : Math.min(targets.length - 1, at + 1);\n    targets[next]?.focus();\n  }\n\n  const Root = interactive ? \"nav\" : \"div\";\n  // Buttons and spans share one head, so a chosen step keeps focus as it becomes the current one.\n  const head = cn(\n    \"group/head m-0 grid cursor-default rounded-(--space-2) border-0 bg-none p-0 text-start text-inherit [-webkit-tap-highlight-color:transparent] focus-visible:[--focus-outline-offset:4px] data-clickable:cursor-pointer\",\n    vertical\n      ? cn(\n          \"w-full items-start\",\n          compact ? \"grid-cols-[var(--marker)] gap-x-0\" : \"grid-cols-[var(--marker)_minmax(0,1fr)] gap-x-3\",\n        )\n      : \"w-fit max-w-full justify-items-start gap-y-3\",\n  );\n  return (\n    <Root\n      className={cn(\n        \"min-w-0 font-body tracking-body text-ink [--halo:4px] [--marker:1.75rem]\",\n        !vertical && \"@container/stepper w-full\",\n        className,\n      )}\n      aria-label={label}\n      role={interactive ? undefined : \"group\"}\n    >\n      {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- delegated arrow-key navigation between the step buttons */}\n      <ol\n        className=\"m-0 grid list-none p-0\"\n        style={\n          vertical\n            ? undefined\n            : { gridTemplateColumns: count > 1 ? `repeat(${count - 1}, minmax(0, 1fr)) auto` : \"auto\" }\n        }\n        onKeyDown={interactive ? onKeyDown : undefined}\n      >\n        {steps.map((step, index) => {\n          const isCurrent = index === active;\n          const status: StepperStatus = step.error\n            ? \"error\"\n            : index < active\n              ? \"complete\"\n              : isCurrent\n                ? \"current\"\n                : \"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            <>\n              <Marker\n                number={index + 1}\n                kind={kind}\n                status={status}\n                current={isCurrent}\n                clickable={clickable}\n                glyphDelay={delayAt(index)}\n                ringDelay={ringDelay}\n                reduced={reduced}\n              />\n              <span className={cn(\"grid min-w-0\", compact ? srOnly : vertical ? \"pt-1\" : srOnlyNarrow)}>\n                <span\n                  className={cn(\n                    \"block text-(length:--text-sm) leading-5 font-medium [overflow-wrap:break-word] transition-[color] duration-standard ease-standard motion-reduce:transition-none\",\n                    labelTone[status],\n                    clickable && \"group-hover/head:text-ink\",\n                  )}\n                >\n                  {step.label}\n                </span>\n                {statusText[status] ? <span className={srOnly}>, {statusText[status]}</span> : null}\n                <SwapText text={detail} className={step.error ? errorText : descriptionText} reduced={reduced} />\n              </span>\n            </>\n          );\n          return (\n            <li\n              key={step.id}\n              className={cn(\n                \"relative min-w-0\",\n                vertical ? (compact ? \"not-last:pb-6\" : \"not-last:pb-5\") : \"not-last:pe-4\",\n              )}\n              data-status={status}\n            >\n              {index < count - 1 ? (\n                <span\n                  className={cn(\n                    \"pointer-events-none absolute overflow-hidden rounded-full bg-line\",\n                    vertical\n                      ? \"top-[calc(var(--marker)_+_var(--halo)_+_2px)] bottom-[calc(var(--halo)_+_2px)] w-0.5 [inset-inline-start:calc(var(--marker)_/_2_-_1px)]\"\n                      : \"top-[calc(var(--marker)_/_2_-_1px)] h-0.5 [inset-inline:calc(var(--marker)_+_var(--halo)_+_var(--space-1))_calc(var(--halo)_+_var(--space-1))]\",\n                  )}\n                  aria-hidden=\"true\"\n                >\n                  <motion.span\n                    key={orientation}\n                    className={cn(\n                      \"absolute inset-0 rounded-[inherit] bg-accent\",\n                      vertical ? \"origin-top\" : \"origin-left rtl:origin-right\",\n                    )}\n                    initial={false}\n                    animate={vertical ? { scaleY: index < active ? 1 : 0 } : { scaleX: index < active ? 1 : 0 }}\n                    transition={reduced ? still : { ...motionTokens.spring.smooth, delay: delayAt(index) }}\n                  />\n                </span>\n              ) : null}\n              {interactive ? (\n                <button\n                  type=\"button\"\n                  className={head}\n                  data-clickable={clickable || undefined}\n                  data-reachable={index <= active || undefined}\n                  aria-current={isCurrent ? \"step\" : undefined}\n                  aria-disabled={clickable ? undefined : true}\n                  tabIndex={clickable ? undefined : -1}\n                  onClick={clickable ? () => onStepSelect?.(index) : undefined}\n                >\n                  {content}\n                </button>\n              ) : (\n                <span className={head} aria-current={isCurrent ? \"step\" : undefined}>\n                  {content}\n                </span>\n              )}\n            </li>\n          );\n        })}\n      </ol>\n      {vertical ? null : (\n        <span className={cn(\"mt-3 hidden\", compact ? \"grid\" : \"@max-[30rem]/stepper:grid\")} aria-hidden=\"true\">\n          <SwapText\n            text={now?.label ?? completeLabel}\n            className=\"block text-(length:--text-sm) leading-5 font-medium text-ink\"\n            reduced={reduced}\n          />\n          <SwapText\n            text={now?.error ?? now?.description}\n            className={now?.error ? errorText : descriptionText}\n            reduced={reduced}\n          />\n        </span>\n      )}\n      <span className={srOnly} aria-live=\"polite\">\n        {now ? `Step ${active + 1} of ${count}: ${now.label}` : completeLabel}\n      </span>\n    </Root>\n  );\n}\n\nexport default Stepper;\n",
      "type": "registry:component",
      "target": "@components/iris/stepper/stepper.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/stepper",
  "meta": {
    "kind": "component",
    "group": "Feedback",
    "export": "Stepper",
    "tags": [
      "motion",
      "product"
    ],
    "docs": "https://irisui.dev/components/stepper"
  }
}
