{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"steps-form","type":"registry:component","title":"Steps form","description":"A multi-step form in one card that changes shape: steps slide in from the way you’re going, checks shake and explain, and Create turns into what comes next.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/status-button.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/steps-form.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AlertCircle } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * STEPS FORM: one card that walks you through, and changes shape\n *\n *   header    a bar of segments fills as you go; the step's name\n *             and title morph letter by letter and \"2 / 4\" rolls\n *   step      the next step slides in from the side you're going\n *             (and back from the other side), out of a blur, while\n *             the card eases to its height\n *   check     Continue runs the step's check: a message shakes the\n *             step and folds open under it; an async check shows\n *             \"Checking\" in the button first\n *   back      Back folds out of the footer on the first step\n *   submit    on the last step Continue morphs into your action;\n *             it spins, draws a check, and the whole card turns into\n *             what comes next\n *\n * It's a real form: Enter continues, and focus moves to the new\n * step's first field.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FormStep {\n  id: string;\n  /** Short name for the header, e.g. \"Plan\". */\n  label: string;\n  title: string;\n  description?: React.ReactNode;\n  content: React.ReactNode;\n  /** Return a message to stop on this step, or nothing to go on. */\n  validate?: () => string | null | undefined | void | Promise<string | null | undefined | void>;\n}\n\nexport interface StepsFormProps extends Omit<React.FormHTMLAttributes<HTMLFormElement>, \"onSubmit\" | \"children\"> {\n  steps: FormStep[];\n  /** Runs after the last step's check; throw (with a message) to stay. */\n  onSubmit: () => void | Promise<void>;\n  /** The last button's labels: idle, while it runs, and when it's done. */\n  submitLabels?: Partial<Record<\"idle\" | \"pending\" | \"success\", string>>;\n  /** What the card becomes once it's submitted. */\n  done?: React.ReactNode;\n  onStepChange?: (index: number) => void;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** The part that changes: eases to each step's height, slides the new one in over the old one leaving. */\nfunction Panel({ id, dir, children, reduced }: { id: string; dir: number; children: React.ReactNode; reduced: boolean }) {\n  const innerRef = React.useRef<HTMLDivElement>(null);\n  const [height, setHeight] = React.useState<number | null>(null);\n  const [ghost, setGhost] = React.useState<{ id: string; node: React.ReactNode; dir: number } | null>(null);\n  const last = React.useRef({ id, node: children });\n\n  React.useLayoutEffect(() => {\n    const el = innerRef.current;\n    if (!el) return;\n    const measure = () => setHeight(el.offsetHeight);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [id]);\n\n  React.useLayoutEffect(() => {\n    if (id === last.current.id) {\n      last.current.node = children;\n      return;\n    }\n    if (!reduced) setGhost({ id: last.current.id, node: last.current.node, dir });\n    last.current = { id, node: children };\n    if (reduced) return;\n    innerRef.current?.animate(\n      [\n        { opacity: 0, transform: `translateX(${dir * 28}px)`, filter: \"blur(4px)\" },\n        { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n      ],\n      { duration: 420, delay: 60, easing: EASE, fill: \"backwards\" },\n    );\n  }, [id, children, dir, reduced]);\n\n  return (\n    <div className=\"relative overflow-hidden\" style={{ height: height ?? undefined, transition: reduced || height === null ? \"none\" : `height 440ms ${EASE}` }}>\n      {ghost && (\n        <div\n          key={`ghost-${ghost.id}`}\n          aria-hidden=\"true\"\n          inert\n          className=\"pointer-events-none absolute inset-x-0 top-0\"\n          ref={(el) => {\n            if (!el || el.dataset.out) return;\n            el.dataset.out = \"1\";\n            const a = el.animate(\n              [\n                { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n                { opacity: 0, transform: `translateX(${ghost.dir * -28}px)`, filter: \"blur(4px)\" },\n              ],\n              { duration: 260, easing: EASE, fill: \"forwards\" },\n            );\n            a.onfinish = () => setGhost((g) => (g?.id === ghost.id ? null : g));\n          }}\n        >\n          {ghost.node}\n        </div>\n      )}\n      <div key={id} ref={innerRef}>\n        {children}\n      </div>\n    </div>\n  );\n}\n\nexport function StepsForm({\n  steps,\n  onSubmit,\n  submitLabels,\n  done,\n  onStepChange,\n  className = \"\",\n  ...props\n}: StepsFormProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const formRef = React.useRef<HTMLFormElement>(null);\n  const bodyRef = React.useRef<HTMLDivElement>(null);\n  const [index, setIndex] = React.useState(0);\n  const [dir, setDir] = React.useState(1);\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const [error, setError] = React.useState<string | null>(null);\n  const [finished, setFinished] = React.useState(false);\n  const [moved, setMoved] = React.useState(false);\n  const step = steps[index];\n  const last = index === steps.length - 1;\n  const working = status === \"pending\" || status === \"success\";\n\n  const go = (next: number) => {\n    setDir(next > index ? 1 : -1);\n    setIndex(next);\n    setError(null);\n    setStatus(\"idle\");\n    setMoved(true);\n    onStepChange?.(next);\n  };\n\n  // A new step: put the cursor in its first field (not on first load, which shouldn't steal focus).\n  React.useEffect(() => {\n    if (!moved) return;\n    const field = bodyRef.current?.querySelector<HTMLElement>(\"[data-autofocus], input:not([type=hidden]), select, textarea\");\n    field?.focus({ preventScroll: true });\n  }, [index, moved]);\n\n  const shake = () => {\n    if (reduced) return;\n    bodyRef.current?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(-6px)\" }, { transform: \"translateX(5px)\" }, { transform: \"translateX(-3px)\" }, { transform: \"translateX(2px)\" }, { transform: \"none\" }],\n      { duration: 380, easing: \"ease-out\" },\n    );\n  };\n\n  const next = async () => {\n    if (working) return;\n    setError(null);\n    const check = step.validate?.();\n    if (check instanceof Promise) setStatus(\"pending\");\n    const message = await check;\n    if (message) {\n      setStatus(\"idle\");\n      setError(message);\n      shake();\n      return;\n    }\n    if (!last) return go(index + 1);\n    setStatus(\"pending\");\n    try {\n      await onSubmit();\n      setStatus(\"success\");\n      window.setTimeout(() => setFinished(true), reduced ? 0 : 750);\n    } catch (e) {\n      setStatus(\"error\");\n      setError(e instanceof Error && e.message ? e.message : \"That didn’t work. Try again.\");\n      shake();\n    }\n  };\n\n  const labels = last\n    ? { idle: submitLabels?.idle ?? \"Create\", pending: submitLabels?.pending ?? \"Creating\", success: submitLabels?.success ?? \"Created\", error: \"Try again\" }\n    : { idle: \"Continue\", pending: \"Checking\", success: \"Continue\", error: \"Continue\" };\n\n  return (\n    <form\n      ref={formRef}\n      noValidate\n      aria-labelledby={`${id}-title`}\n      onSubmit={(e) => {\n        e.preventDefault();\n        void next();\n      }}\n      // Once they start fixing it, the message folds away.\n      onInput={() => error && status !== \"error\" && setError(null)}\n      onChange={() => error && status !== \"error\" && setError(null)}\n      className={`@container w-full rounded-[20px] border border-border bg-card text-card-foreground ${className}`}\n      {...props}\n    >\n      {/* Header: folds away once it's done. */}\n      <div className=\"grid\" style={{ gridTemplateRows: finished ? \"0fr\" : \"1fr\", transition: reduced ? \"none\" : `grid-template-rows 420ms ${EASE}` }}>\n        <div className=\"overflow-hidden\">\n          <div className=\"px-4 pt-4 @md:px-5 @md:pt-5\">\n            <div className=\"flex items-center justify-between text-[12px] text-muted-foreground\">\n              <TextMorph>{step.label}</TextMorph>\n              <span className=\"tabular-nums\" aria-hidden=\"true\">\n                <NumberRoll value={index + 1} duration={450} /> / {steps.length}\n              </span>\n            </div>\n            <div className=\"mt-2 flex gap-1\" aria-hidden=\"true\">\n              {steps.map((s, i) => (\n                <span key={s.id} className=\"h-1 flex-1 overflow-hidden rounded-full bg-muted\">\n                  <span\n                    className=\"block h-full origin-left rounded-full bg-primary\"\n                    style={{\n                      transform: `scaleX(${i <= index || finished ? 1 : 0})`,\n                      transition: reduced ? \"none\" : `transform 520ms ${EASE} ${i === index && dir > 0 ? 80 : 0}ms`,\n                    }}\n                  />\n                </span>\n              ))}\n            </div>\n            <h2 id={`${id}-title`} className=\"mt-4 text-[18px] font-semibold leading-tight tracking-tight text-foreground @md:text-[20px]\">\n              <TextMorph>{step.title}</TextMorph>\n            </h2>\n          </div>\n        </div>\n      </div>\n\n      <div ref={bodyRef} className=\"px-4 @md:px-5\">\n        <Panel id={finished ? \"done\" : step.id} dir={finished ? 1 : dir} reduced={reduced}>\n          {finished ? (\n            <div className=\"py-5\">{done}</div>\n          ) : (\n            <div className=\"pb-1 pt-1.5\">\n              {step.description && <div className=\"mb-4 text-[13px] leading-relaxed text-muted-foreground\">{step.description}</div>}\n              {step.content}\n            </div>\n          )}\n        </Panel>\n        {/* What stopped you, folding open under the step. */}\n        <div className=\"grid\" style={{ gridTemplateRows: error && !finished ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 300ms ${EASE}` }}>\n          <div className=\"overflow-hidden\">\n            <p role=\"alert\" className=\"flex items-start gap-1.5 pt-2.5 text-[12.5px] leading-snug text-red-600 dark:text-red-400\">\n              {error && <AlertCircle aria-hidden=\"true\" className=\"mt-px size-3.5 shrink-0\" />}\n              {error}\n            </p>\n          </div>\n        </div>\n      </div>\n\n      {/* Footer: Back folds away on the first step, everything folds away once it's done. */}\n      <div className=\"grid\" style={{ gridTemplateRows: finished ? \"0fr\" : \"1fr\", transition: reduced ? \"none\" : `grid-template-rows 420ms ${EASE}` }}>\n        <div className=\"overflow-hidden\">\n          <div className=\"flex items-center gap-2 p-4 pt-5 @md:p-5\">\n            <div className=\"grid\" style={{ gridTemplateColumns: index > 0 ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-columns 360ms ${EASE}` }}>\n              <div className=\"overflow-hidden\">\n                <button\n                  type=\"button\"\n                  tabIndex={index > 0 ? 0 : -1}\n                  aria-hidden={index === 0}\n                  disabled={working}\n                  onClick={() => go(index - 1)}\n                  className={`h-9 whitespace-nowrap rounded-full px-3.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50 ${FOCUS}`}\n                >\n                  Back\n                </button>\n              </div>\n            </div>\n            <StatusButton type=\"submit\" status={status} labels={labels} className=\"ml-auto\" />\n          </div>\n        </div>\n      </div>\n    </form>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/steps-form"}