{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"morphing-button","type":"registry:component","title":"Morphing button","description":"Shrinks into a spinner while it saves, then answers with a check or a shake.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/morphing-button.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Status = \"idle\" | \"loading\" | \"success\" | \"error\";\n\n// No bounce: a button that overshoots its own width looks unsure of itself.\nconst MORPH = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n// Matches h-10, so the compact state is a true circle.\nconst CIRCLE = 40;\n// Swings that die away, like a head shaking no.\nconst SHAKE: Keyframe[] = [\n  { translate: \"0\" },\n  { translate: \"-6px\" },\n  { translate: \"5px\" },\n  { translate: \"-3px\" },\n  { translate: \"2px\" },\n  { translate: \"0\" },\n];\n\nconst ANNOUNCE: Record<Status, string> = {\n  idle: \"\",\n  loading: \"Saving\",\n  success: \"Changes saved\",\n  error: \"Couldn't save. Try again.\",\n};\n\nexport function MorphingButton({\n  onSave,\n  label = \"Save changes\",\n  retryLabel = \"Try again\",\n  // Long enough to register the check, short enough to be ready again soon.\n  successFor = 1500,\n  className,\n}: {\n  onSave: () => Promise<void>;\n  label?: string;\n  retryLabel?: string;\n  successFor?: number;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const reset = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const shake = useRef<Animation>(undefined);\n  const attempt = useRef(0);\n\n  useEffect(\n    () => () => {\n      attempt.current++;\n      clearTimeout(reset.current);\n      shake.current?.cancel();\n    },\n    [],\n  );\n\n  const busy = status === \"loading\" || status === \"success\";\n  const compact = busy;\n\n  const save = async () => {\n    if (busy) return;\n    const id = ++attempt.current;\n    clearTimeout(reset.current);\n    shake.current?.cancel();\n    setStatus(\"loading\");\n    try {\n      await onSave();\n      if (id !== attempt.current) return;\n      setStatus(\"success\");\n      reset.current = setTimeout(() => setStatus(\"idle\"), successFor);\n    } catch {\n      if (id !== attempt.current) return;\n      setStatus(\"error\");\n      if (reduceMotion) return;\n      // 350ms, past the usual cap: a shake needs a few swings to read as\n      // \"no\". Waits 150ms for the width to mostly open so it shakes the\n      // settled shape, not one still growing.\n      shake.current = buttonRef.current?.animate(SHAKE, {\n        duration: 350,\n        delay: 150,\n        easing: \"ease-in-out\",\n      });\n    }\n  };\n\n  return (\n    <>\n      <motion.button\n        ref={buttonRef}\n        type=\"button\"\n        // aria-disabled rather than disabled, so keyboard focus survives the\n        // loading state instead of dropping to the page.\n        aria-disabled={busy}\n        aria-label={\n          status === \"loading\"\n            ? \"Saving\"\n            : status === \"success\"\n              ? \"Saved\"\n              : status === \"error\"\n                ? retryLabel\n                : label\n        }\n        onClick={save}\n        initial={false}\n        animate={{ width: compact ? CIRCLE : \"auto\" }}\n        transition={reduceMotion ? { duration: 0 } : MORPH}\n        className={cn(\n          \"relative flex h-10 touch-manipulation items-center justify-center overflow-hidden rounded-full px-5 text-sm font-medium outline-hidden select-none\",\n          \"transition-[scale,background-color,color] duration-200 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n          \"active:scale-[0.96] aria-disabled:cursor-default aria-disabled:active:scale-100 motion-reduce:transition-[background-color,color]\",\n          status === \"error\"\n            ? \"bg-destructive text-white\"\n            : \"bg-foreground text-background\",\n          className,\n        )}\n      >\n        {/* Every layer shares one grid cell, so the idle width is the widest\n            label and text is never squeezed. In the circle the cell overflows\n            both sides equally and the button clips it. */}\n        <span aria-hidden className=\"grid shrink-0 place-items-center\">\n          <Text visible={status === \"idle\"}>{label}</Text>\n          <Text visible={status === \"error\"}>{retryLabel}</Text>\n          <Icon visible={status === \"loading\"} reduceMotion={reduceMotion}>\n            <g className=\"origin-center animate-[spin_700ms_linear_infinite]\">\n              <circle cx=\"8\" cy=\"8\" r=\"6\" className=\"opacity-25\" />\n              <path d=\"M8 2a6 6 0 0 1 6 6\" />\n            </g>\n          </Icon>\n          <Icon visible={status === \"success\"} reduceMotion={reduceMotion}>\n            <path d=\"m3.5 8.5 3 3 6-7\" />\n          </Icon>\n        </span>\n      </motion.button>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {ANNOUNCE[status]}\n      </span>\n    </>\n  );\n}\n\nfunction Text({\n  visible,\n  children,\n}: {\n  visible: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <span\n      className={cn(\n        \"col-start-1 row-start-1 whitespace-nowrap transition-[opacity,filter] ease-out\",\n        // Leaves in 150ms, before the width has closed on it; arrives in\n        // 200ms as the width opens.\n        visible ? \"duration-200\" : \"opacity-0 blur-[4px] duration-150\",\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\nfunction Icon({\n  visible,\n  reduceMotion,\n  children,\n}: {\n  visible: boolean;\n  reduceMotion: boolean | null;\n  children: React.ReactNode;\n}) {\n  const hidden = reduceMotion\n    ? { opacity: 0 }\n    : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" };\n  return (\n    <motion.svg\n      viewBox=\"0 0 16 16\"\n      className=\"col-start-1 row-start-1 size-4\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      // Heavier than the lab's usual 1.5: alone in the circle, the icon is\n      // the whole message and has no text to lean on.\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      initial={false}\n      animate={visible ? { scale: 1, opacity: 1, filter: \"blur(0px)\" } : hidden}\n      transition={ICON_SWAP}\n    >\n      {children}\n    </motion.svg>\n  );\n}\n\n// Long enough to see the spinner turn, short for a save.\nconst LATENCY = 1200;\n\nexport default function MorphingButtonDemo() {\n  const saves = useRef(0);\n  const timers = useRef(new Set<ReturnType<typeof setTimeout>>());\n\n  useEffect(() => {\n    const pending = timers.current;\n    return () => pending.forEach(clearTimeout);\n  }, []);\n\n  // Every other save fails, so both endings are one click apart.\n  const save = () => {\n    const fail = saves.current++ % 2 === 1;\n    return new Promise<void>((resolve, reject) => {\n      const t = setTimeout(() => {\n        timers.current.delete(t);\n        if (fail) reject(new Error(\"Save failed\"));\n        else resolve();\n      }, LATENCY);\n      timers.current.add(t);\n    });\n  };\n\n  return <MorphingButton onSave={save} />;\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"}],"categories":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/morphing-button"}