{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "multi-step-form",
  "type": "registry:ui",
  "title": "Multi-step Form",
  "description": "Steps slide the way you travel, validate one at a time, and keep every answer.",
  "categories": [
    "forms"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/multi-step-form.tsx",
      "content": "\"use client\";\nimport { Form } from \"@base-ui/react/form\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls } from \"motion/react\";\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowLeft, Pencil } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type MultiStepFormStepDef = {\n  id: string;\n  /** The step's heading. */\n  title: string;\n  /** A shorter name for the progress track, e.g. \"Team\" for \"Invite your team\". */\n  label?: string;\n  description?: string;\n};\nexport type MultiStepFormValues = Record<string, FormDataEntryValue | FormDataEntryValue[]>;\ntype Status = \"idle\" | \"checking\" | \"submitting\" | \"done\" | \"error\";\ntype ContinueHandler = (values: MultiStepFormValues) => boolean | void | Promise<boolean | void>;\n\ntype Ctx = {\n  steps: MultiStepFormStepDef[];\n  index: number;\n  furthest: number;\n  returnTo: number | null;\n  status: Status;\n  values: MultiStepFormValues;\n  labels: Required<MultiStepFormLabels>;\n  requestStep: (i: number) => void;\n  back: () => void;\n  edit: (id: string) => void;\n  registerStep: (id: string, el: HTMLElement | null) => void;\n  registerHandler: (id: string, fn: ContinueHandler | undefined) => void;\n  stepEl: (i: number) => HTMLElement | undefined;\n  takeFocus: () => boolean;\n};\nconst MultiStepContext = createContext<Ctx | null>(null);\n\n/** The form's state, for parts you compose yourself (a custom review, a step counter). */\nexport function useMultiStepForm() {\n  const ctx = useContext(MultiStepContext);\n  if (!ctx) throw new Error(\"useMultiStepForm must be used inside <MultiStepForm>\");\n  return ctx;\n}\n\nexport type MultiStepFormLabels = {\n  back?: string;\n  continue?: string;\n  backToReview?: string;\n  submit?: string;\n  submitting?: string;\n  done?: string;\n  error?: string;\n};\n\nexport type MultiStepFormProps = Omit<React.ComponentProps<\"form\">, \"onSubmit\" | \"children\"> & {\n  steps: MultiStepFormStepDef[];\n  /** The current step's id, when you control it. */\n  step?: string;\n  defaultStep?: string;\n  onStepChange?: (id: string) => void;\n  /** Runs after every step validates. Return or resolve to finish; throw to show the error line. */\n  onComplete?: (values: MultiStepFormValues) => void | Promise<void>;\n  labels?: MultiStepFormLabels;\n  children: React.ReactNode;\n};\n\nconst defaultLabels: Required<MultiStepFormLabels> = {\n  back: \"Back\",\n  continue: \"Continue\",\n  backToReview: \"Back to review\",\n  submit: \"Submit\",\n  submitting: \"Submitting…\",\n  done: \"Done\",\n  error: \"Couldn’t submit. Check your connection and try again.\",\n};\n\n/**\n * One native form across every step. Steps stay mounted (hidden when inactive), so\n * values, touched state and errors survive going back and forth, and the final\n * submit sees every field. Continue validates only the current step's fields.\n */\nexport function MultiStepForm({\n  steps,\n  step: stepProp,\n  defaultStep,\n  onStepChange,\n  onComplete,\n  labels: labelsProp,\n  className,\n  children,\n  ref,\n  ...rest\n}: MultiStepFormProps) {\n  const [stepId, setStepId] = useControllableState({ value: stepProp, defaultValue: defaultStep ?? steps[0]?.id ?? \"\", onChange: onStepChange });\n  const index = Math.max(0, steps.findIndex((s) => s.id === stepId));\n  const [furthest, setFurthest] = useState(index);\n  const [returnTo, setReturnTo] = useState<number | null>(null);\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const [values, setValues] = useState<MultiStepFormValues>({});\n  const labels = { ...defaultLabels, ...labelsProp };\n\n  const form = useRef<HTMLFormElement | null>(null);\n  const actions = useRef<Form.Actions | null>(null);\n  const stepEls = useRef(new Map<string, HTMLElement>());\n  const handlers = useRef(new Map<string, ContinueHandler>());\n  const busy = useRef(false);\n  const focusNext = useRef(false);\n\n  const setFormRef = useCallback(\n    (node: HTMLFormElement | null) => {\n      form.current = node;\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n\n  const snapshot = useCallback((): MultiStepFormValues => {\n    if (!form.current) return {};\n    const data = new FormData(form.current);\n    const out: MultiStepFormValues = {};\n    for (const key of new Set(data.keys())) {\n      const all = data.getAll(key);\n      out[key] = all.length > 1 ? all : all[0];\n    }\n    return out;\n  }, []);\n\n  const go = (i: number) => {\n    const target = steps[i];\n    if (!target || i === index) return;\n    setValues(snapshot());\n    setFurthest((f) => Math.max(f, i));\n    if (returnTo === i) setReturnTo(null);\n    if (status === \"error\") setStatus(\"idle\");\n    // Focus follows the step only when the person moved it, never on mount or from props.\n    focusNext.current = !!form.current?.contains(document.activeElement);\n    setStepId(target.id);\n  };\n\n  // Validates one step: asks Base UI to show each named field's error, then reads the\n  // browser's verdict. Focuses the first invalid control when the step is on screen.\n  const validateStep = async (i: number, focus: boolean, withHandler = true) => {\n    const def = steps[i];\n    const el = def && stepEls.current.get(def.id);\n    if (!el) return true;\n    const controls = [...el.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(\"input, select, textarea\")].filter(\n      (c) => !c.disabled && c.name,\n    );\n    for (const name of new Set(controls.map((c) => c.name))) actions.current?.validate(name);\n    const invalid = controls.find((c) => !c.checkValidity());\n    if (invalid) {\n      if (focus) {\n        invalid.focus();\n        if (invalid instanceof HTMLInputElement && /^(text|email|search|tel|url)$/.test(invalid.type)) invalid.select();\n      }\n      return false;\n    }\n    const handler = withHandler ? handlers.current.get(def.id) : undefined;\n    if (!handler) return true;\n    const result = handler(snapshot());\n    if (result && typeof (result as Promise<unknown>).then === \"function\") {\n      setStatus(\"checking\");\n      try {\n        const ok = await result;\n        return ok !== false;\n      } catch {\n        return false;\n      } finally {\n        setStatus(\"idle\");\n      }\n    }\n    return result !== false;\n  };\n\n  const advance = async () => {\n    if (busy.current || status === \"done\") return;\n    busy.current = true;\n    try {\n      if (!(await validateStep(index, true))) return;\n      const last = steps.length - 1;\n      if (index < last) {\n        go(returnTo ?? index + 1);\n        return;\n      }\n      // Final submit: every earlier step must still hold. Send the person to the first that doesn't.\n      for (let i = 0; i < last; i++) {\n        // Fields only: each step's own server check already passed when it was left.\n        if (!(await validateStep(i, false, false))) {\n          go(i);\n          return;\n        }\n      }\n      setStatus(\"submitting\");\n      try {\n        await onComplete?.(snapshot());\n        setStatus(\"done\");\n      } catch {\n        setStatus(\"error\");\n      }\n    } finally {\n      busy.current = false;\n    }\n  };\n\n  const requestStep = async (i: number) => {\n    if (i <= index) return go(i);\n    if (i > furthest || busy.current) return;\n    busy.current = true;\n    try {\n      if (await validateStep(index, true)) go(i);\n    } finally {\n      busy.current = false;\n    }\n  };\n\n  // Stable, because steps and content read them inside effects.\n  const registerStep = useCallback((id: string, el: HTMLElement | null) => {\n    if (el) stepEls.current.set(id, el);\n    else stepEls.current.delete(id);\n  }, []);\n  const registerHandler = useCallback((id: string, fn: ContinueHandler | undefined) => {\n    if (fn) handlers.current.set(id, fn);\n    else handlers.current.delete(id);\n  }, []);\n  const stepEl = useCallback((i: number) => (steps[i] ? stepEls.current.get(steps[i].id) : undefined), [steps]);\n  const takeFocus = useCallback(() => {\n    const f = focusNext.current;\n    focusNext.current = false;\n    return f;\n  }, []);\n\n  const ctx: Ctx = {\n    steps,\n    index,\n    furthest,\n    returnTo,\n    status,\n    values,\n    labels,\n    requestStep: (i) => void requestStep(i),\n    back: () => go(index - 1),\n    edit: (id) => {\n      const i = steps.findIndex((s) => s.id === id);\n      if (i < 0) return;\n      setReturnTo(index);\n      go(i);\n    },\n    registerStep,\n    registerHandler,\n    stepEl,\n    takeFocus,\n  };\n\n  return (\n    <MultiStepContext value={ctx}>\n      <Form\n        ref={setFormRef}\n        actionsRef={actions}\n        data-status={status}\n        // Every submit (the Continue button, Enter in a field) is handled here, so\n        // Base UI never validates the steps that aren't on screen.\n        onSubmitCapture={(e) => {\n          e.preventDefault();\n          e.stopPropagation();\n          void advance();\n        }}\n        className={cn(\"flex w-full flex-col gap-5\", className)}\n        {...rest}\n      >\n        {children}\n      </Form>\n    </MultiStepContext>\n  );\n}\n\nexport type MultiStepFormProgressProps = React.ComponentProps<\"nav\">;\n\n/** Step count, a segmented track that fills as you go, and labels you can press to go back. */\nexport function MultiStepFormProgress({ className, ...rest }: MultiStepFormProgressProps) {\n  const { steps, index, furthest, requestStep, status } = useMultiStepForm();\n  const reduce = useReducedMotion();\n  const locked = status === \"submitting\" || status === \"done\";\n  return (\n    <nav aria-label=\"Progress\" className={cn(\"@container/progress flex flex-col gap-2.5\", className)} {...rest}>\n      <p className=\"font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">\n        Step <NumberFlow value={index + 1} className=\"tabular text-fg-2\" /> of {steps.length}\n      </p>\n      <ol className=\"grid gap-1.5\" style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}>\n        {steps.map((s, i) => {\n          const state = i < index ? \"complete\" : i === index ? \"current\" : i <= furthest ? \"visited\" : \"upcoming\";\n          const reachable = !locked && i !== index && i <= furthest;\n          return (\n            <li key={s.id} className=\"min-w-0\">\n              <button\n                type=\"button\"\n                data-state={state}\n                disabled={!reachable}\n                aria-current={i === index ? \"step\" : undefined}\n                onClick={() => requestStep(i)}\n                className={cn(\n                  \"group/seg relative flex w-full min-w-0 flex-col gap-2 rounded-md text-left outline-none\",\n                  \"focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n                  \"disabled:cursor-default enabled:active:scale-[0.98] transition-[scale] duration-100\",\n                  \"before:absolute before:-inset-y-3 before:inset-x-0 before:content-['']\",\n                )}\n              >\n                <span className=\"relative h-1 overflow-hidden rounded-full bg-fg/10\">\n                  <motion.span\n                    className=\"absolute inset-0 origin-left rounded-full bg-fg\"\n                    initial={false}\n                    animate={{ scaleX: i <= index ? 1 : 0, opacity: i < index ? 0.55 : 1 }}\n                    transition={reduce ? { duration: 0 } : { duration: 0.42, ease: ease.inOut, delay: i === index ? 0.08 : 0 }}\n                  />\n                </span>\n                <span\n                  className={cn(\n                    \"flex min-w-0 items-center gap-1 text-[11.5px] transition-colors duration-150\",\n                    state === \"current\" && \"font-medium text-fg\",\n                    (state === \"complete\" || state === \"visited\") && \"text-fg-2 group-enabled/seg:group-hover/seg:text-fg\",\n                    state === \"upcoming\" && \"text-fg-4\",\n                    // In a narrow form only the current label shows; the rest stay readable to screen readers.\n                    state !== \"current\" && \"@max-[22rem]/progress:sr-only\",\n                  )}\n                >\n                  <AnimatePresence initial={false}>\n                    {state === \"complete\" && (\n                      <motion.svg\n                        key=\"tick\"\n                        width=\"12\"\n                        height=\"12\"\n                        viewBox=\"0 0 16 16\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth={1.8}\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        aria-hidden\n                        className=\"shrink-0 text-fg-2\"\n                        initial={reduce ? { opacity: 0 } : { opacity: 0, width: 0, marginRight: -4 }}\n                        animate={{ opacity: 1, width: 12, marginRight: 0 }}\n                        exit={{ opacity: 0, width: 0, marginRight: -4, transition: { duration: 0.14 } }}\n                        transition={reduce ? { duration: 0.15 } : spring.snappy}\n                      >\n                        <motion.path\n                          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                          initial={reduce ? false : { pathLength: 0 }}\n                          animate={{ pathLength: 1 }}\n                          transition={{ duration: 0.3, ease: ease.out, delay: 0.12 }}\n                        />\n                      </motion.svg>\n                    )}\n                  </AnimatePresence>\n                  <span className=\"truncate\">{s.label ?? s.title}</span>\n                  {state === \"complete\" && <span className=\"sr-only\">, done</span>}\n                </span>\n              </button>\n            </li>\n          );\n        })}\n      </ol>\n    </nav>\n  );\n}\n\nexport type MultiStepFormContentProps = React.ComponentProps<\"div\">;\n\n/** Holds the steps. Its height glides between steps while the content slides the way you're going. */\nexport function MultiStepFormContent({ className, children, ...rest }: MultiStepFormContentProps) {\n  const { index, stepEl } = useMultiStepForm();\n  const reduce = useReducedMotion();\n  const box = useRef<HTMLDivElement>(null);\n  const settled = useRef(0);\n  const moving = useRef<AnimationPlaybackControls | null>(null);\n  const first = useRef(true);\n\n  // At rest the height is auto (errors can grow a step freely); remember it for the next move.\n  useEffect(() => {\n    const el = box.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => {\n      if (!moving.current) settled.current = el.offsetHeight;\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  useLayoutEffect(() => {\n    const el = box.current;\n    const target = stepEl(index);\n    if (first.current) {\n      first.current = false;\n      return;\n    }\n    if (!el || !target || reduce) return;\n    // Interrupted mid-move: start from wherever the height is now.\n    const from = moving.current ? el.offsetHeight : settled.current;\n    moving.current?.stop();\n    const to = target.offsetHeight;\n    el.dataset.moving = \"\";\n    const controls = animate(el, { height: [from, to] }, { duration: 0.34, ease: ease.inOut });\n    moving.current = controls;\n    controls.then(() => {\n      if (moving.current !== controls) return;\n      moving.current = null;\n      el.style.height = \"\";\n      delete el.dataset.moving;\n      settled.current = el.offsetHeight;\n    });\n  }, [index, stepEl, reduce]);\n\n  return (\n    <div ref={box} className={cn(\"grid data-moving:overflow-hidden\", className)} {...rest}>\n      {children}\n    </div>\n  );\n}\n\nexport type MultiStepFormStepProps = Omit<React.ComponentProps<\"section\">, \"id\"> & {\n  id: string;\n  /** Runs after the step's fields pass. Return false (or resolve false) to stay, e.g. after a server check. */\n  onContinue?: ContinueHandler;\n  /** Hide the built-in title and description, when the step renders its own. */\n  hideHeader?: boolean;\n};\n\nexport function MultiStepFormStep({ id, onContinue, hideHeader = false, className, children, ...rest }: MultiStepFormStepProps) {\n  const ctx = useMultiStepForm();\n  const { steps, index, registerStep, registerHandler, takeFocus } = ctx;\n  const reduce = useReducedMotion();\n  const mine = steps.findIndex((s) => s.id === id);\n  const def = steps[mine];\n  const active = mine === index;\n  const heading = useRef<HTMLHeadingElement>(null);\n\n  // Mounted always, displayed only while active or leaving.\n  const [shown, setShown] = useState(active);\n  if (active && !shown) setShown(true);\n\n  useEffect(() => {\n    registerHandler(id, onContinue);\n    return () => registerHandler(id, undefined);\n  }, [id, onContinue, registerHandler]);\n\n  useEffect(() => {\n    if (active && takeFocus()) heading.current?.focus({ preventScroll: true });\n  }, [active, takeFocus]);\n\n  const side = mine < index ? -1 : mine > index ? 1 : 0;\n  const titleId = `${id}-step-title`;\n\n  return (\n    <motion.section\n      ref={(el: HTMLElement | null) => registerStep(id, el)}\n      aria-labelledby={hideHeader ? undefined : titleId}\n      hidden={!shown}\n      inert={!active}\n      data-state={active ? \"active\" : \"inactive\"}\n      initial={false}\n      // The same values render on the server and the client; reduced motion only zeroes the travel's duration.\n      animate={{ x: side * 28, opacity: active ? 1 : 0, filter: active ? \"blur(0px)\" : \"blur(2px)\" }}\n      transition={\n        reduce\n          ? { duration: 0.15, x: { duration: 0 }, filter: { duration: 0 } }\n          : active\n            ? { duration: 0.34, ease: ease.out, delay: 0.04 }\n            : { duration: 0.2, ease: ease.in }\n      }\n      onAnimationComplete={() => {\n        if (!active) setShown(false);\n      }}\n      className={cn(\"col-start-1 row-start-1 flex min-w-0 flex-col gap-4 self-start\", className)}\n      {...(rest as object)}\n    >\n      {!hideHeader && def && (\n        <header className=\"flex flex-col gap-0.5\">\n          <h3 ref={heading} id={titleId} tabIndex={-1} className=\"text-[14px] font-medium tracking-[-0.015em] text-fg outline-none [text-wrap:balance]\">\n            {def.title}\n          </h3>\n          {def.description && <p className=\"text-[12px] leading-[1.45] text-fg-3 [text-wrap:pretty]\">{def.description}</p>}\n        </header>\n      )}\n      {children}\n    </motion.section>\n  );\n}\n\nexport type MultiStepFormActionsProps = React.ComponentProps<\"div\">;\n\n/** Back and Continue. Continue becomes Back to review after an edit, and the submit label on the last step. */\nexport function MultiStepFormActions({ className, ...rest }: MultiStepFormActionsProps) {\n  const { index, steps, back, returnTo, status, labels } = useMultiStepForm();\n  const reduce = useReducedMotion();\n  const last = index === steps.length - 1;\n  const locked = status === \"submitting\" || status === \"done\";\n  const label =\n    status === \"submitting\" ? labels.submitting : status === \"done\" ? labels.done : last ? labels.submit : returnTo != null ? labels.backToReview : labels.continue;\n  const glyph = status === \"submitting\" || status === \"checking\" ? \"spin\" : status === \"done\" ? \"tick\" : null;\n\n  return (\n    <div className={cn(\"flex flex-col gap-3\", className)} {...rest}>\n      <AnimatePresence initial={false}>\n        {status === \"error\" && (\n          <motion.p\n            role=\"alert\"\n            className=\"overflow-hidden text-[12px] leading-[1.45] text-danger\"\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0, transition: { duration: 0.15 } }}\n            transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n          >\n            {labels.error}\n          </motion.p>\n        )}\n      </AnimatePresence>\n      <div className=\"flex items-center justify-between gap-3\">\n        <button\n          type=\"button\"\n          onClick={back}\n          inert={index === 0 || locked}\n          className={cn(\n            \"group/back relative inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[12.5px] font-medium text-fg-2 outline-none\",\n            \"transition-[background-color,color,opacity,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75\",\n            \"focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n            (index === 0 || locked) && \"pointer-events-none opacity-0\",\n          )}\n        >\n          <ArrowLeft size={14} className=\"-ml-0.5 transition-transform duration-200 ease-out-expo group-hover/back:-translate-x-0.5\" />\n          {labels.back}\n        </button>\n        <ContinueButton label={label} glyph={glyph} busy={status === \"submitting\" || status === \"checking\"} done={status === \"done\"} />\n      </div>\n    </div>\n  );\n}\n\n// The primary button's width glides between \"Continue\" and the longer submit label,\n// while the label itself slides and the spinner or tick takes its place in front.\nfunction ContinueButton({ label, glyph, busy, done }: { label: string; glyph: \"spin\" | \"tick\" | null; busy: boolean; done: boolean }) {\n  const reduce = useReducedMotion();\n  const sizer = useRef<HTMLSpanElement>(null);\n  // -1 until measured, so the first paint is simply auto.\n  const width = useMotionValue(-1);\n  const cssWidth = useTransform(width, (w) => (w < 0 ? \"auto\" : w));\n  useLayoutEffect(() => {\n    const w = sizer.current?.offsetWidth;\n    if (!w) return;\n    if (width.get() < 0 || reduce) width.set(w);\n    else animate(width, w, spring.snappy);\n  }, [label, glyph, width, reduce]);\n\n  const content = (\n    <>\n      {glyph && (\n        <span className=\"grid size-4 place-items-center\">\n          {glyph === \"spin\" ? (\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" aria-hidden className=\"animate-spin [animation-duration:0.75s]\">\n              <circle cx=\"8\" cy=\"8\" r=\"5.75\" opacity=\"0.25\" />\n              <path d=\"M8 2.25a5.75 5.75 0 0 1 5.75 5.75\" />\n            </svg>\n          ) : (\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n              <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.3, ease: ease.out, delay: 0.08 }} />\n            </svg>\n          )}\n        </span>\n      )}\n      <span>{label}</span>\n    </>\n  );\n\n  return (\n    <>\n      <button\n        type=\"submit\"\n        aria-busy={busy || undefined}\n        aria-disabled={busy || done || undefined}\n        className={cn(\n          \"relative inline-flex h-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-fg px-3 text-[12.5px] font-medium text-frame shadow-[var(--shadow)] outline-none\",\n          \"transition-[background-color,scale] duration-150 ease-out focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n          busy || done ? \"bg-fg/85\" : \"hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n        )}\n      >\n        <motion.span className=\"relative block h-5\" style={{ width: cssWidth }}>\n          <span ref={sizer} aria-hidden className=\"invisible absolute left-0 top-0 inline-flex h-5 items-center gap-1.5 whitespace-nowrap\">\n            {content}\n          </span>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={`${label}-${glyph}`}\n              className=\"absolute inset-0 flex items-center justify-center gap-1.5 whitespace-nowrap\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n            >\n              {content}\n            </motion.span>\n          </AnimatePresence>\n        </motion.span>\n      </button>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {busy ? label : done ? label : \"\"}\n      </span>\n    </>\n  );\n}\n\nexport type MultiStepFormSummaryItem = { step: string; label: string; value: React.ReactNode };\nexport type MultiStepFormSummaryProps = React.ComponentProps<\"div\"> & {\n  items: MultiStepFormSummaryItem[];\n  /** Shown for values that are empty. */\n  emptyValue?: React.ReactNode;\n};\n\n/** The review: answers grouped by step, each group with an Edit that returns here when done. */\nexport function MultiStepFormSummary({ items, emptyValue = \"Not set\", className, ...rest }: MultiStepFormSummaryProps) {\n  const { steps, edit, status } = useMultiStepForm();\n  const groups = steps.map((s) => ({ step: s, rows: items.filter((it) => it.step === s.id) })).filter((g) => g.rows.length);\n  const locked = status === \"submitting\" || status === \"done\";\n  return (\n    <div className={cn(\"flex flex-col divide-y divide-line overflow-hidden rounded-xl border border-line bg-raised\", className)} {...rest}>\n      {groups.map(({ step, rows }) => (\n        <section key={step.id} aria-label={step.title} className=\"flex flex-col gap-1.5 px-3 py-2.5\">\n          <div className=\"flex items-center justify-between gap-2\">\n            <h4 className=\"font-mono text-2xs uppercase tracking-[0.08em] text-fg-3\">{step.label ?? step.title}</h4>\n            <button\n              type=\"button\"\n              disabled={locked}\n              onClick={() => edit(step.id)}\n              aria-label={`Edit ${(step.label ?? step.title).toLowerCase()}`}\n              className={cn(\n                \"group/edit relative -my-1 -mr-1.5 inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[12px] text-fg-2 outline-none\",\n                \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.95] active:duration-75 disabled:opacity-50\",\n                \"focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n                \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n              )}\n            >\n              <Pencil size={12} className=\"transition-transform duration-200 ease-out-expo group-hover/edit:-rotate-12\" />\n              Edit\n            </button>\n          </div>\n          <dl className=\"grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-x-3 gap-y-1 text-[12.5px]\">\n            {rows.map((r) => (\n              <div key={r.label} className=\"contents\">\n                <dt className=\"truncate text-fg-3\">{r.label}</dt>\n                <dd className=\"min-w-0 break-words text-fg\">{r.value === \"\" || r.value == null ? <span className=\"text-fg-4\">{emptyValue}</span> : r.value}</dd>\n              </div>\n            ))}\n          </dl>\n        </section>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/multi-step-form"
}
