{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"multi-step-form","type":"registry:component","title":"Multi-step form","description":"Slides between steps while the card grows around each one.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/multi-step-form.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  type Variants,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Plan = { id: string; name: string; note: string; price: string };\n\ntype Panel = 0 | 1 | 2 | \"done\";\n\nconst STEPS = [\"Name\", \"Plan\", \"Review\"] as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// No bounce: a card edge that overshoots reads as the layout being unsure.\nconst HEIGHT = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// Far enough to read as direction, short enough to stay a nudge, not a pan.\nconst SHIFT = 36;\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n\n// The old step leaves faster and travels less than the new one arrives, so\n// the eye goes straight to what is coming in.\nconst slide: Variants = {\n  enter: (d: number) => ({ x: d * SHIFT, opacity: 0, filter: \"blur(4px)\" }),\n  center: {\n    x: 0,\n    opacity: 1,\n    filter: \"blur(0px)\",\n    transition: { duration: 0.3, ease: EASE_OUT },\n  },\n  exit: (d: number) => ({\n    x: d * -SHIFT * 0.7,\n    opacity: 0,\n    filter: \"blur(4px)\",\n    transition: { duration: 0.15, ease: EASE_OUT },\n  }),\n};\n\n// Reduced motion keeps the cross-fade and drops the travel and blur.\nconst fade: Variants = {\n  enter: { opacity: 0 },\n  center: { opacity: 1, transition: { duration: 0.2, ease: EASE_OUT } },\n  exit: { opacity: 0, transition: { duration: 0.12, ease: EASE_OUT } },\n};\n\nconst DEFAULT_PLANS: Plan[] = [\n  { id: \"hobby\", name: \"Hobby\", note: \"One project, community support\", price: \"Free\" },\n  { id: \"pro\", name: \"Pro\", note: \"Unlimited projects, analytics\", price: \"$12/mo\" },\n  { id: \"team\", name: \"Team\", note: \"Shared billing and roles\", price: \"$36/mo\" },\n];\n\nexport function MultiStepForm({\n  plans = DEFAULT_PLANS,\n  onCreate,\n  className,\n}: {\n  plans?: Plan[];\n  onCreate?: (value: { name: string; plan: string }) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const uid = useId();\n  const [panel, setPanel] = useState<Panel>(0);\n  const [direction, setDirection] = useState<1 | -1>(1);\n  const [name, setName] = useState(\"\");\n  const [planId, setPlanId] = useState<string | null>(null);\n\n  const bodyRef = useRef<HTMLDivElement>(null);\n  const innerRef = useRef<HTMLDivElement>(null);\n  const height = useMotionValue(0);\n  const [measured, setMeasured] = useState(false);\n  const hasMeasured = useRef(false);\n  const hasMounted = useRef(false);\n\n  const plan = plans.find((p) => p.id === planId);\n  const valid =\n    panel === 0 ? name.trim().length >= 2 : panel === 1 ? !!plan : true;\n\n  // Measured rather than animated with `layout`, so the height is right even\n  // when text wraps differently, and the content itself is never scaled.\n  useLayoutEffect(() => {\n    const el = innerRef.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => {\n      const next = el.offsetHeight;\n      if (!hasMeasured.current || reduceMotion) {\n        height.jump(next);\n        hasMeasured.current = true;\n        setMeasured(true);\n      } else {\n        animate(height, next, HEIGHT);\n      }\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [height, reduceMotion]);\n\n  // Move focus into the new step so keyboard and screen reader users land\n  // where the change happened. Skipped on mount so a page load never steals\n  // focus.\n  useEffect(() => {\n    if (!hasMounted.current) {\n      hasMounted.current = true;\n      return;\n    }\n    bodyRef.current\n      ?.querySelector<HTMLElement>(`[data-panel=\"${panel}\"] [data-autofocus]`)\n      ?.focus({ preventScroll: true });\n  }, [panel]);\n\n  const go = (next: Panel, dir: 1 | -1) => {\n    setDirection(dir);\n    setPanel(next);\n  };\n\n  const advance = () => {\n    if (!valid) return;\n    if (panel === 0) go(1, 1);\n    else if (panel === 1) go(2, 1);\n    else if (panel === 2) {\n      go(\"done\", 1);\n      if (planId) onCreate?.({ name: name.trim(), plan: planId });\n    } else {\n      setName(\"\");\n      setPlanId(null);\n      go(0, -1);\n    }\n  };\n\n  const back = () => {\n    if (panel === 1) go(0, -1);\n    else if (panel === 2) go(1, -1);\n  };\n\n  const stepIndex = panel === \"done\" ? 3 : panel;\n  const primary =\n    panel === 2 ? \"Create\" : panel === \"done\" ? \"Start over\" : \"Continue\";\n\n  const content = (p: Panel, prefix: string) => (\n    <PanelContent\n      panel={p}\n      prefix={prefix}\n      plans={plans}\n      name={name}\n      onNameChange={setName}\n      planId={planId}\n      onPlanChange={setPlanId}\n      plan={plan}\n      reduceMotion={reduceMotion}\n    />\n  );\n\n  return (\n    // The card grows up from a fixed bottom, so Back and Continue never move\n    // under the cursor. An invisible copy holding every step at once reserves\n    // the tallest height, which keeps the outer box a constant size.\n    <div className={cn(\"grid w-[420px] max-w-full\", className)}>\n      <div\n        aria-hidden\n        inert\n        className=\"invisible col-start-1 row-start-1 flex flex-col\"\n      >\n        <Header stepIndex={stepIndex} />\n        <div className=\"grid\">\n          {([0, 1, 2, \"done\"] as const).map((p) => (\n            <div key={p} className=\"col-start-1 row-start-1 p-6\">\n              {content(p, `${uid}-ghost`)}\n            </div>\n          ))}\n        </div>\n        <Footer canGoBack={false} valid={false} primary={primary} onBack={() => {}} />\n      </div>\n\n      <form\n        noValidate\n        aria-label=\"Create a workspace\"\n        className=\"col-start-1 row-start-1 flex flex-col self-end overflow-hidden rounded-[20px] bg-muted shadow-raised\"\n        onSubmit={(e) => {\n          e.preventDefault();\n          advance();\n        }}\n        onKeyDown={(e) => {\n          // Enter submits the step from any field, radios included, which\n          // browsers disagree on. Buttons keep their own Enter behavior.\n          if (e.key !== \"Enter\" || e.nativeEvent.isComposing) return;\n          if ((e.target as HTMLElement).tagName === \"BUTTON\") return;\n          e.preventDefault();\n          advance();\n        }}\n      >\n        <Header stepIndex={stepIndex} />\n        <motion.div\n          ref={bodyRef}\n          className=\"overflow-hidden\"\n          style={{ height: measured ? height : \"auto\" }}\n        >\n          <div ref={innerRef} className=\"relative\">\n            <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n              <motion.div\n                key={panel}\n                data-panel={panel}\n                custom={direction}\n                variants={reduceMotion ? fade : slide}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n                className=\"p-6\"\n              >\n                {content(panel, uid)}\n              </motion.div>\n            </AnimatePresence>\n          </div>\n        </motion.div>\n        <Footer\n          canGoBack={panel === 1 || panel === 2}\n          valid={valid}\n          primary={primary}\n          onBack={back}\n        />\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {panel === \"done\"\n            ? \"Workspace created\"\n            : `Step ${panel + 1} of 3, ${STEPS[panel]}`}\n        </span>\n      </form>\n    </div>\n  );\n}\n\nfunction Header({ stepIndex }: { stepIndex: number }) {\n  return (\n    <div className=\"flex items-center justify-between gap-4 px-6 pt-6\">\n      <span className=\"text-sm text-muted-foreground tabular-nums\">\n        {stepIndex < 3 ? `Step ${stepIndex + 1} of 3` : \"Complete\"}\n      </span>\n      <div aria-hidden className=\"flex gap-1.5\">\n        {STEPS.map((step, i) => (\n          <span\n            key={step}\n            className=\"h-1.5 w-8 overflow-hidden rounded-full bg-foreground/15\"\n          >\n            {/* Fills left to right when reached and drains right to left when\n                you step back, so the bar always points the way you moved. */}\n            <span\n              className={cn(\n                \"block h-full rounded-full bg-foreground transition-[scale] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\",\n                i <= stepIndex\n                  ? \"origin-left scale-x-100\"\n                  : \"origin-right scale-x-0\",\n              )}\n            />\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction Footer({\n  canGoBack,\n  valid,\n  primary,\n  onBack,\n}: {\n  canGoBack: boolean;\n  valid: boolean;\n  primary: string;\n  onBack: () => void;\n}) {\n  return (\n    <div className=\"flex items-center justify-between border-t border-border p-4\">\n      {/* Always rendered, so Continue keeps its place when Back is hidden. */}\n      <button\n        type=\"button\"\n        onClick={onBack}\n        disabled={!canGoBack}\n        className={cn(\n          \"h-11 touch-manipulation rounded-full px-4 text-[15px] font-medium text-muted-foreground outline-hidden transition-[scale,opacity,color,background-color] duration-150 ease-out select-none hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\",\n          !canGoBack && \"pointer-events-none opacity-0\",\n        )}\n      >\n        Back\n      </button>\n      <button\n        type=\"submit\"\n        disabled={!valid}\n        className=\"h-11 touch-manipulation rounded-full bg-foreground px-5 text-[15px] font-medium text-background outline-hidden transition-[scale,opacity] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground enabled:active:scale-[0.96] disabled:cursor-not-allowed disabled:opacity-40\"\n      >\n        {/* Every label shares one grid cell, so the button holds the width of\n            the longest and never resizes as the label changes. */}\n        <span className=\"grid\">\n          {[\"Continue\", \"Create\", \"Start over\"].map((label) => (\n            <span\n              key={label}\n              aria-hidden={label !== primary}\n              className={cn(\n                \"col-start-1 row-start-1 transition-[opacity,filter] duration-200 ease-out\",\n                label !== primary && \"opacity-0 blur-[4px]\",\n              )}\n            >\n              {label}\n            </span>\n          ))}\n        </span>\n      </button>\n    </div>\n  );\n}\n\nfunction PanelContent({\n  panel,\n  prefix,\n  plans,\n  name,\n  onNameChange,\n  planId,\n  onPlanChange,\n  plan,\n  reduceMotion,\n}: {\n  panel: Panel;\n  prefix: string;\n  plans: Plan[];\n  name: string;\n  onNameChange: (name: string) => void;\n  planId: string | null;\n  onPlanChange: (id: string) => void;\n  plan: Plan | undefined;\n  reduceMotion: boolean | null;\n}) {\n  if (panel === 0) {\n    return (\n      <div className=\"flex flex-col gap-4\">\n        <Heading title=\"Name your workspace\" note=\"You can change this later.\" />\n        <label htmlFor={`${prefix}-name`} className=\"sr-only\">\n          Workspace name\n        </label>\n        <input\n          id={`${prefix}-name`}\n          data-autofocus\n          value={name}\n          onChange={(e) => onNameChange(e.target.value)}\n          placeholder=\"Acme Inc\"\n          autoComplete=\"organization\"\n          className=\"h-11 rounded-xl bg-background px-3.5 text-[15px] text-foreground shadow-raised outline-hidden placeholder:text-muted-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\"\n        />\n      </div>\n    );\n  }\n\n  if (panel === 1) {\n    return (\n      <div className=\"flex flex-col gap-4\">\n        <Heading title=\"Choose a plan\" note=\"Switch any time from settings.\" />\n        <div role=\"radiogroup\" aria-label=\"Plan\" className=\"flex flex-col gap-2\">\n          {plans.map((p, i) => {\n            const checked = p.id === planId;\n            return (\n              <label\n                key={p.id}\n                className={cn(\n                  \"flex cursor-pointer items-center gap-3.5 rounded-xl bg-background px-4 py-3 shadow-raised transition-[scale,box-shadow] duration-150 ease-out select-none active:scale-[0.98] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-foreground\",\n                  checked && \"shadow-[0_0_0_1.5px_var(--foreground)]\",\n                )}\n              >\n                <input\n                  type=\"radio\"\n                  name={`${prefix}-plan`}\n                  value={p.id}\n                  checked={checked}\n                  onChange={() => onPlanChange(p.id)}\n                  // The checked option, or the first when none is, is where\n                  // arrow keys start from.\n                  data-autofocus={checked || (!planId && i === 0) ? \"\" : undefined}\n                  className=\"sr-only\"\n                />\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"flex size-5 shrink-0 items-center justify-center rounded-full shadow-[inset_0_0_0_1.5px_var(--border)] transition-[box-shadow] duration-150 ease-out\",\n                    checked && \"shadow-[inset_0_0_0_1.5px_var(--foreground)]\",\n                  )}\n                >\n                  <span\n                    className={cn(\n                      \"size-2.5 rounded-full bg-foreground transition-[scale,opacity] duration-150 ease-out\",\n                      checked ? \"scale-100 opacity-100\" : \"scale-[0.25] opacity-0\",\n                    )}\n                  />\n                </span>\n                <span className=\"flex min-w-0 flex-1 flex-col\">\n                  <span className=\"text-[15px] font-medium text-foreground\">{p.name}</span>\n                  <span className=\"truncate text-sm text-muted-foreground\">{p.note}</span>\n                </span>\n                <span className=\"text-sm text-muted-foreground tabular-nums\">{p.price}</span>\n              </label>\n            );\n          })}\n        </div>\n      </div>\n    );\n  }\n\n  if (panel === 2) {\n    return (\n      <div className=\"flex flex-col gap-4\">\n        <Heading title=\"Review\" note=\"Check the details, then create.\" focusable />\n        <dl className=\"flex flex-col gap-2.5 rounded-xl bg-background px-4 py-3 text-[15px] shadow-raised\">\n          <Row term=\"Name\" value={name.trim() || \"Untitled\"} />\n          <Row term=\"Plan\" value={plan?.name ?? \"None\"} />\n          <Row term=\"Billed\" value={plan?.price ?? \"Free\"} />\n        </dl>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex flex-col items-center gap-4 py-3 text-center\">\n      <motion.svg\n        viewBox=\"0 0 16 16\"\n        className=\"size-6 text-foreground\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden\n        initial={\n          reduceMotion\n            ? { opacity: 0 }\n            : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" }\n        }\n        animate={{ scale: 1, opacity: 1, filter: \"blur(0px)\" }}\n        // One beat late, so the check lands after the panel has arrived.\n        transition={{ ...ICON_SWAP, delay: 0.08 }}\n      >\n        <path d=\"m3.5 8.5 3 3 6-7\" />\n      </motion.svg>\n      <div className=\"flex w-full min-w-0 flex-col gap-1.5\">\n        <h3\n          tabIndex={-1}\n          data-autofocus\n          className=\"text-[20px] font-medium text-foreground outline-hidden\"\n        >\n          Workspace created\n        </h3>\n        {/* Truncated here and in the review, so a long name can never make\n            the reserved height grow. */}\n        <p className=\"truncate text-[15px] text-muted-foreground\">\n          {name.trim() || \"Untitled\"} is ready on {plan?.name ?? \"Hobby\"}.\n        </p>\n      </div>\n    </div>\n  );\n}\n\nfunction Heading({\n  title,\n  note,\n  focusable,\n}: {\n  title: string;\n  note: string;\n  focusable?: boolean;\n}) {\n  return (\n    <div className=\"flex flex-col gap-1.5\">\n      <h3\n        tabIndex={focusable ? -1 : undefined}\n        data-autofocus={focusable ? \"\" : undefined}\n        className=\"text-[20px] font-medium text-foreground outline-hidden\"\n      >\n        {title}\n      </h3>\n      <p className=\"text-[15px] text-muted-foreground\">{note}</p>\n    </div>\n  );\n}\n\nfunction Row({ term, value }: { term: string; value: string }) {\n  return (\n    <div className=\"flex items-center justify-between gap-4\">\n      <dt className=\"text-muted-foreground\">{term}</dt>\n      <dd className=\"min-w-0 truncate font-medium text-foreground\">{value}</dd>\n    </div>\n  );\n}\n\nexport default function MultiStepFormDemo() {\n  return <MultiStepForm />;\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/multi-step-form"}