{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stepper",
  "type": "registry:ui",
  "title": "Stepper",
  "description": "Wizard steps whose numbers draw into ticks as the rail fills.",
  "categories": [
    "navigation"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/stepper.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Children, createContext, isValidElement, use, useCallback, useMemo, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * The step logic on its own\n * -----------------------------------------------------------------------------------------------*/\n\n/** Where a wizard is and how to move it. `value === count` means every step is done. */\nexport function useStepper({ count, defaultValue = 0 }: { count: number; defaultValue?: number }) {\n  const [value, setValue] = useState(defaultValue);\n  const goTo = useCallback((i: number) => setValue(Math.min(Math.max(0, i), count)), [count]);\n  return {\n    value,\n    setValue: goTo,\n    next: () => goTo(value + 1),\n    back: () => goTo(value - 1),\n    reset: () => goTo(0),\n    isFirst: value === 0,\n    isLast: value === count - 1,\n    isComplete: value >= count,\n  };\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Stepper\n * -----------------------------------------------------------------------------------------------*/\n\ntype Orientation = \"horizontal\" | \"vertical\";\nexport type StepStatus = \"complete\" | \"current\" | \"upcoming\" | \"error\" | \"loading\";\n\ntype Ctx = {\n  value: number;\n  count: number;\n  orientation: Orientation;\n  linear: boolean;\n  instant: boolean;\n  go: (index: number, e: React.MouseEvent) => void;\n};\nconst StepperContext = createContext<Ctx | null>(null);\nconst IndexContext = createContext(0);\n\nexport type StepperProps = Omit<React.ComponentProps<\"ol\">, \"onChange\"> & {\n  /** The current step, from 0. Equal to the number of steps when everything is done. */\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  orientation?: Orientation;\n  /** Linear flows only let people go back to completed steps. Set false to allow jumping ahead. */\n  linear?: boolean;\n};\n\nexport function Stepper({ value: valueProp, defaultValue = 0, onValueChange, orientation = \"horizontal\", linear = true, className, children, ...rest }: StepperProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  // A step chosen with the keyboard renders on the same frame; a click animates.\n  const [instant, setInstant] = useState(false);\n  const steps = Children.toArray(children).filter(isValidElement<StepperStepProps>);\n  const current = value >= steps.length ? \"All steps complete\" : steps[value]?.props.title;\n\n  const ctx = useMemo<Ctx>(\n    () => ({\n      value,\n      count: steps.length,\n      orientation,\n      linear,\n      instant,\n      go: (index, e) => {\n        setInstant(e.detail === 0);\n        setValue(index);\n      },\n    }),\n    [value, steps.length, orientation, linear, instant, setValue],\n  );\n\n  return (\n    <StepperContext value={ctx}>\n      <ol\n        aria-label={rest[\"aria-label\"] ?? \"Progress\"}\n        data-orientation={orientation}\n        className={cn(\n          orientation === \"horizontal\" ? \"@container flex w-full flex-wrap items-start\" : \"flex flex-col\",\n          className,\n        )}\n        {...rest}\n      >\n        {steps.map((step, i) => (\n          <IndexContext key={step.key ?? i} value={i}>\n            {step}\n          </IndexContext>\n        ))}\n        {orientation === \"horizontal\" && (\n          // In a narrow container the labels step aside for one line about where you are.\n          <li aria-hidden className=\"mt-3 hidden min-w-0 basis-full truncate text-[12.5px] @max-[30rem]:block\">\n            <span className=\"font-medium text-fg\">{current}</span>\n            <span className=\"text-fg-3\"> · Step {Math.min(value + 1, steps.length)} of {steps.length}</span>\n          </li>\n        )}\n      </ol>\n    </StepperContext>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * A step\n * -----------------------------------------------------------------------------------------------*/\n\nexport type StepperStepProps = Omit<React.ComponentProps<\"li\">, \"title\"> & {\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  /** Marks the step as failed and shows the message in place of the description. */\n  error?: React.ReactNode;\n  /** Shows a spinner on the step, for while it validates or saves. */\n  loading?: boolean;\n  /** Adds a quiet \"Optional\" label. */\n  optional?: boolean;\n  disabled?: boolean;\n  /** Vertical only: content shown under the current step. */\n  children?: React.ReactNode;\n};\n\nexport function StepperStep({ title, description, error, loading = false, optional = false, disabled = false, className, children, ...rest }: StepperStepProps) {\n  const ctx = use(StepperContext);\n  const index = use(IndexContext);\n  if (!ctx) throw new Error(\"StepperStep must be inside a Stepper\");\n  const { value, count, orientation, linear, instant, go } = ctx;\n\n  const status: StepStatus = error ? \"error\" : index < value ? \"complete\" : index === value ? (loading ? \"loading\" : \"current\") : \"upcoming\";\n  const last = index === count - 1;\n  const done = index < value;\n  const clickable = !disabled && index !== value && (index < value || !linear);\n  const vertical = orientation === \"vertical\";\n\n  const label = (\n    <>\n      <Indicator status={status} index={index} instant={instant} />\n      <span className={cn(\"flex min-w-0 max-w-full flex-col text-left\", vertical ? \"pt-[3px]\" : \"@max-[30rem]:sr-only\")}>\n        <span className=\"flex items-baseline gap-1.5\">\n          <span\n            className={cn(\n              \"truncate text-[13px] font-medium tracking-[-0.01em] transition-colors duration-200\",\n              status === \"error\" ? \"text-danger\" : status === \"upcoming\" ? \"text-fg-3\" : \"text-fg\",\n              clickable && \"group-hover/step:text-fg\",\n            )}\n          >\n            <span className=\"sr-only\">\n              Step {index + 1} of {count}:{\" \"}\n            </span>\n            {title}\n          </span>\n          {optional && <span className=\"shrink-0 text-[11px] text-fg-4\">Optional</span>}\n        </span>\n        {(error || description) && (\n          <span className={cn(\"text-[12px] leading-4\", error ? \"text-danger/90\" : \"text-fg-3\", !vertical && \"truncate\")}>{error || description}</span>\n        )}\n        <span className=\"sr-only\">{statusText[status]}</span>\n      </span>\n    </>\n  );\n\n  const labelClass = cn(\n    \"group/step relative flex min-w-0 items-start rounded-lg text-left outline-none\",\n    vertical ? \"gap-2.5\" : \"max-w-full flex-col gap-2\",\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-4 focus-visible:outline-fg-3\",\n    disabled && \"opacity-50\",\n  );\n\n  return (\n    <li\n      aria-current={index === value ? \"step\" : undefined}\n      data-status={status}\n      data-disabled={disabled || undefined}\n      className={cn(\n        // Horizontal steps share the row equally; the last one only takes what its label needs, so the rail ends at its circle.\n        vertical ? \"grid grid-cols-[24px_minmax(0,1fr)] gap-x-3\" : cn(\"relative flex min-w-0 items-start\", last ? \"flex-none\" : \"flex-1 pr-4\"),\n        className,\n      )}\n      {...rest}\n    >\n      {vertical ? (\n        <>\n          <div className=\"col-start-1 row-start-1 flex flex-col items-center\">\n            {/* The label sits in the second column; its button spans both so the circle is part of the target. */}\n            <Connector filled={done} hidden={last} vertical instant={instant} />\n          </div>\n          <div className=\"col-start-1 row-start-1 col-span-2 flex min-w-0 flex-col\">\n            {clickable ? (\n              <button type=\"button\" onClick={(e) => go(index, e)} className={cn(labelClass, \"transition-transform duration-150 ease-out active:scale-[0.98]\")}>\n                {label}\n              </button>\n            ) : (\n              <div className={labelClass}>{label}</div>\n            )}\n            <Panel open={index === value && !!children} instant={instant}>\n              {children}\n            </Panel>\n            {!last && <div className=\"h-5\" />}\n          </div>\n        </>\n      ) : (\n        <>\n          {clickable ? (\n            <button type=\"button\" onClick={(e) => go(index, e)} className={cn(labelClass, \"transition-transform duration-150 ease-out active:scale-[0.97]\")}>\n              {label}\n            </button>\n          ) : (\n            <div className={labelClass}>{label}</div>\n          )}\n          {/* Runs from this circle to the next one, under the labels' row. */}\n          {!last && <Connector filled={done} instant={instant} />}\n        </>\n      )}\n    </li>\n  );\n}\n\nconst statusText: Record<StepStatus, string> = {\n  complete: \", completed\",\n  current: \", current step\",\n  upcoming: \"\",\n  error: \", needs attention\",\n  loading: \", saving\",\n};\n\n/* -------------------------------------------------------------------------------------------------\n * Parts\n * -----------------------------------------------------------------------------------------------*/\n\n// The number becomes a drawn tick when the step completes, an exclamation when it fails,\n// and gains a turning arc while it saves. The circle's fill and ring change in CSS.\nfunction Indicator({ status, index, instant }: { status: StepStatus; index: number; instant: boolean }) {\n  const reduce = useReducedMotion();\n  const glyph = status === \"complete\" ? \"check\" : status === \"error\" ? \"error\" : \"number\";\n  const swap = reduce || instant ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } : { initial: { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }, animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" }, exit: { opacity: 0, scale: 0.5, filter: \"blur(2px)\" } };\n\n  return (\n    <span\n      aria-hidden\n      data-status={status}\n      className={cn(\n        \"relative grid size-6 shrink-0 place-items-center rounded-full border text-[11.5px] font-medium tabular\",\n        \"transition-[background-color,border-color,color,box-shadow,scale] duration-200 ease-out motion-reduce:transition-none\",\n        \"group-hover/step:border-fg-3 group-active/step:scale-[0.94]\",\n        status === \"upcoming\" && \"border-line-2 bg-raised text-fg-3\",\n        (status === \"current\" || status === \"loading\") && \"border-fg bg-raised text-fg ring-4 ring-fg/[0.07]\",\n        status === \"complete\" && \"border-fg bg-fg text-frame group-hover/step:border-fg group-hover/step:bg-fg/85\",\n        status === \"error\" && \"border-danger/60 bg-danger-soft text-danger ring-4 ring-danger/[0.08] group-hover/step:border-danger\",\n        instant && \"duration-0\",\n      )}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span key={glyph} className=\"absolute inset-0 grid place-items-center\" {...swap} transition={instant ? { duration: 0 } : reduce ? { duration: 0.15 } : spring.pop}>\n          {glyph === \"check\" ? (\n            <svg width={12} height={12} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <motion.path\n                d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                initial={reduce || instant ? false : { pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={{ duration: 0.3, ease: ease.out, delay: 0.06 }}\n              />\n            </svg>\n          ) : glyph === \"error\" ? (\n            <svg width={12} height={12} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\">\n              <path d=\"M8 3.75v5\" />\n              <circle cx=\"8\" cy=\"12\" r=\".6\" fill=\"currentColor\" />\n            </svg>\n          ) : (\n            index + 1\n          )}\n        </motion.span>\n      </AnimatePresence>\n      {status === \"loading\" && (\n        <svg className=\"absolute -inset-[3px] size-[calc(100%+6px)] animate-spin-slow text-fg motion-reduce:animate-none\" viewBox=\"0 0 30 30\" fill=\"none\">\n          <circle cx=\"15\" cy=\"15\" r=\"14\" stroke=\"currentColor\" strokeOpacity={0.12} strokeWidth={1.5} />\n          <path d=\"M15 1a14 14 0 0 1 14 14\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" />\n        </svg>\n      )}\n    </span>\n  );\n}\n\n// A hairline track with a fill that runs toward the next step. Advancing fills after the tick\n// has started drawing; going back empties at once, so the rail never lags the click.\nfunction Connector({ filled, vertical = false, hidden = false, instant }: { filled: boolean; vertical?: boolean; hidden?: boolean; instant: boolean }) {\n  const reduce = useReducedMotion();\n  if (hidden) return null;\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"overflow-hidden rounded-full bg-line-2\",\n        vertical ? \"relative mt-[30px] mb-1.5 w-px flex-1\" : \"pointer-events-none absolute top-[11.5px] right-2 left-8 h-px\",\n      )}\n    >\n      <motion.span\n        className={cn(\"absolute inset-0 bg-fg\", vertical ? \"origin-top\" : \"origin-left\")}\n        initial={false}\n        animate={vertical ? { scaleY: filled ? 1 : 0 } : { scaleX: filled ? 1 : 0 }}\n        transition={instant || reduce ? { duration: instant ? 0 : 0.15 } : { duration: filled ? 0.36 : 0.2, ease: ease.inOut, delay: filled ? 0.08 : 0 }}\n      />\n    </span>\n  );\n}\n\n// Vertical steps open their content under the title; the height eases so the steps below\n// move instead of jumping, and the closed panel is inert so its fields leave the tab order.\nfunction Panel({ open, instant, children }: { open: boolean; instant: boolean; children?: React.ReactNode }) {\n  return (\n    <div\n      inert={!open}\n      className={cn(\n        \"grid transition-[grid-template-rows,opacity] duration-260 ease-in-out-quart motion-reduce:transition-none\",\n        open ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\",\n        instant && \"duration-0\",\n      )}\n    >\n      <div className=\"min-h-0 overflow-hidden pl-[34px]\">{children && <div className=\"pt-3\">{children}</div>}</div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/stepper"
}
