{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"copy-button","type":"registry:component","title":"Copy button","description":"The two pages merge into one before a check draws itself in.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/copy-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\" | \"copied\" | \"failed\";\n\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n\nexport function CopyButton({\n  value,\n  label = \"Copy to clipboard\",\n  // Long enough to read the tooltip, short enough that a second copy\n  // rarely lands while the first is still confirming.\n  resetAfter = 1600,\n  onCopy,\n  className,\n}: {\n  value: string;\n  label?: string;\n  resetAfter?: number;\n  onCopy?: (value: string) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [status, setStatus] = useState<Status>(\"idle\");\n  // Holds the last message while the tooltip fades out, so it never\n  // empties mid-exit.\n  const [message, setMessage] = useState(\"Copied\");\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  const attempt = useRef(0);\n\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  const show = (next: Exclude<Status, \"idle\">) => {\n    setStatus(next);\n    setMessage(next === \"copied\" ? \"Copied\" : \"Couldn't copy\");\n    clearTimeout(timer.current);\n    timer.current = setTimeout(() => setStatus(\"idle\"), resetAfter);\n  };\n\n  const copy = async () => {\n    const id = ++attempt.current;\n    // Confirm on press rather than after the write resolves; the write is\n    // near instant, and waiting for it makes the click feel ignored.\n    show(\"copied\");\n    try {\n      if (!navigator.clipboard) throw new Error(\"Clipboard unavailable\");\n      await navigator.clipboard.writeText(value);\n      if (id === attempt.current) onCopy?.(value);\n    } catch {\n      // Ignore failures from a click that a newer one has superseded.\n      if (id === attempt.current) show(\"failed\");\n    }\n  };\n\n  const visible = status !== \"idle\";\n\n  return (\n    <span className={cn(\"relative inline-flex\", className)}>\n      <button\n        type=\"button\"\n        aria-label={label}\n        onClick={copy}\n        className={cn(\n          \"relative flex size-8 touch-manipulation items-center justify-center rounded-full text-muted-foreground outline-hidden transition-[scale,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-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[color,background-color]\",\n          // Grows the hit area to 40px without growing the visible circle.\n          \"after:absolute after:-inset-1 after:rounded-full\",\n          visible && \"text-foreground\",\n        )}\n      >\n        {/* Both icons share one grid cell so the swap never shifts layout. */}\n        <span className=\"grid\" aria-hidden>\n          <CopyGlyph copied={status === \"copied\"} reduceMotion={reduceMotion} />\n          <CheckGlyph copied={status === \"copied\"} reduceMotion={reduceMotion} />\n        </span>\n      </button>\n\n      {/* Out of flow, so appearing never nudges the row. Enters in 150ms,\n          leaves in 100ms: the exit should never hold the eye. */}\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 origin-bottom rounded-full bg-foreground px-2 py-1 text-xs font-medium whitespace-nowrap text-background\",\n          \"transition-[opacity,translate,scale] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\",\n          visible\n            ? \"translate-y-0 scale-100 opacity-100 duration-150\"\n            : \"translate-y-1 scale-[0.97] opacity-0 duration-100 motion-reduce:translate-y-0 motion-reduce:scale-100\",\n        )}\n      >\n        {message}\n      </span>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {visible ? message : \"\"}\n      </span>\n    </span>\n  );\n}\n\nconst GLYPH = {\n  viewBox: \"0 0 16 16\",\n  className: \"col-start-1 row-start-1 size-4\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.5,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n} as const;\n\n// The front sheet is offset from the back one by 2.5 units each way, so\n// sliding it back by exactly that lays it over the rear outline.\nconst SHEET_OFFSET = 2.5;\nconst MERGE = { duration: 0.12, ease: [0.77, 0, 0.175, 1] } as const;\n\n// The copy icon acts the copy out: the front sheet slides onto the back one\n// until the two pages are one, and only then does that page give way to a\n// check that draws itself in. Returning, the page splits back into two.\nfunction CopyGlyph({\n  copied,\n  reduceMotion,\n}: {\n  copied: boolean;\n  reduceMotion: boolean | null;\n}) {\n  const shown = { scale: 1, opacity: 1, filter: \"blur(0px)\" };\n  return (\n    <motion.svg\n      {...GLYPH}\n      initial={false}\n      animate={\n        copied\n          ? reduceMotion\n            ? { opacity: 0 }\n            : {\n                scale: 0.25,\n                opacity: 0,\n                filter: \"blur(4px)\",\n                // Waits for the sheets to merge before it leaves.\n                transition: { ...ICON_SWAP, delay: MERGE.duration },\n              }\n          : { ...shown, transition: ICON_SWAP }\n      }\n    >\n      <path d=\"M10.75 5.25V4.5a1.75 1.75 0 0 0-1.75-1.75H4.5A1.75 1.75 0 0 0 2.75 4.5V9a1.75 1.75 0 0 0 1.75 1.75h.75\" />\n      <motion.rect\n        x=\"5.25\"\n        y=\"5.25\"\n        width=\"8\"\n        height=\"8\"\n        rx=\"1.75\"\n        initial={false}\n        animate={\n          copied && !reduceMotion\n            ? { x: -SHEET_OFFSET, y: -SHEET_OFFSET, transition: MERGE }\n            : // Splits a beat after the page has faded back in.\n              { x: 0, y: 0, transition: { ...MERGE, delay: 0.1 } }\n        }\n      />\n    </motion.svg>\n  );\n}\n\nfunction CheckGlyph({\n  copied,\n  reduceMotion,\n}: {\n  copied: boolean;\n  reduceMotion: boolean | null;\n}) {\n  return (\n    <motion.svg\n      {...GLYPH}\n      initial={false}\n      animate={\n        copied\n          ? { opacity: 1, transition: { duration: 0.1, delay: MERGE.duration } }\n          : // Softer than the entrance: fades rather than un-draws.\n            { opacity: 0, transition: { duration: 0.12 } }\n      }\n    >\n      <motion.path\n        d=\"m3.5 8.5 3 3 6-7\"\n        initial={false}\n        animate={\n          copied || reduceMotion\n            ? {\n                pathLength: 1,\n                transition: reduceMotion\n                  ? { duration: 0 }\n                  : // A pen stroke: fast off the mark, easing into the tip.\n                    { duration: 0.22, delay: MERGE.duration, ease: [0.65, 0, 0.35, 1] },\n              }\n            : // Rewinds only once invisible, ready for the next copy.\n              { pathLength: 0, transition: { duration: 0, delay: 0.12 } }\n        }\n      />\n    </motion.svg>\n  );\n}\n\nexport default function CopyButtonDemo() {\n  const command = \"bun add motion\";\n  return (\n    // A 44px pill with 6px padding around the 32px button keeps the radii\n    // concentric: 22 = 16 + 6.\n    <div className=\"flex h-11 items-center gap-3 rounded-full bg-muted pr-1.5 pl-4 shadow-raised\">\n      <code className=\"font-mono text-sm text-foreground\">\n        <span className=\"text-muted-foreground select-none\">$ </span>\n        {command}\n      </code>\n      <CopyButton value={command} label=\"Copy command\" />\n    </div>\n  );\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":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/copy-button"}