{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "copy-button",
  "type": "registry:ui",
  "title": "Copy button",
  "description": "Reports clipboard state in place without shifting nearby content.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "actions"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/copy-button/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Button label resolves in place to copied",
    "sceneFamily": "product-mono",
    "motionRole": "snap",
    "primaryState": "Copy action in its confirmed state",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/copy-button.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/copy-button.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst DRAW = { duration: 0.26, ease: EASE } as const;\nconst INSTANT = { duration: 0 } as const;\n\nexport type CopyStatus = \"idle\" | \"copied\" | \"error\";\n\nexport type UseCopyToClipboardOptions = {\n  timeout?: number;\n  onCopy?: (value: string) => void;\n  onError?: (reason: unknown) => void;\n};\n\nfunction writeFallback(text: string): boolean {\n  const area = document.createElement(\"textarea\");\n  area.value = text;\n  area.setAttribute(\"readonly\", \"\");\n  area.style.position = \"fixed\";\n  area.style.top = \"0\";\n  area.style.left = \"0\";\n  area.style.opacity = \"0\";\n  document.body.appendChild(area);\n\n  const selection = document.getSelection();\n  const previous =\n    selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;\n\n  area.select();\n  let ok = false;\n  try {\n    ok = document.execCommand(\"copy\");\n  } catch {\n    ok = false;\n  }\n\n  document.body.removeChild(area);\n  if (selection && previous) {\n    selection.removeAllRanges();\n    selection.addRange(previous);\n  }\n  return ok;\n}\n\nexport function useCopyToClipboard({\n  timeout = 2000,\n  onCopy,\n  onError,\n}: UseCopyToClipboardOptions = {}) {\n  const [status, setStatus] = useState<CopyStatus>(\"idle\");\n  const [ticket, setTicket] = useState(0);\n\n  const mounted = useRef(true);\n  const copied = useRef(onCopy);\n  copied.current = onCopy;\n  const failed = useRef(onError);\n  failed.current = onError;\n\n  useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n    };\n  }, []);\n\n  const reset = useCallback(() => {\n    setStatus(\"idle\");\n    setTicket(0);\n  }, []);\n\n  const fail = useCallback((reason: unknown) => {\n    if (!mounted.current) return;\n\n    setStatus(\"error\");\n    setTicket((t) => t + 1);\n    failed.current?.(reason);\n  }, []);\n\n  const copy = useCallback(async (text: string) => {\n    if (!text) return false;\n\n    let ok = false;\n    let reason: unknown = null;\n\n    try {\n      if (typeof navigator !== \"undefined\" && navigator.clipboard?.writeText) {\n        await navigator.clipboard.writeText(text);\n        ok = true;\n      } else {\n        ok = writeFallback(text);\n      }\n    } catch (error) {\n      reason = error;\n      try {\n        ok = writeFallback(text);\n      } catch {\n        ok = false;\n      }\n    }\n\n    if (!mounted.current) return ok;\n\n    if (ok) {\n      setStatus(\"copied\");\n      setTicket((t) => t + 1);\n      copied.current?.(text);\n    } else {\n      fail(reason);\n    }\n\n    return ok;\n  }, [fail]);\n\n  useEffect(() => {\n    if (ticket === 0 || status === \"idle\") return;\n    const id = setTimeout(() => setStatus(\"idle\"), timeout);\n    return () => clearTimeout(id);\n  }, [ticket, status, timeout]);\n\n  return { copy, fail, reset, status, copied: status === \"copied\" };\n}\n\nexport type CopyButtonProps = {\n  value: string;\n  label?: string;\n  copiedLabel?: string;\n  errorLabel?: string;\n  timeout?: number;\n  onCopy?: (value: string) => void;\n  onError?: (reason: unknown) => void;\n  onIntent?: () => void;\n  resolveValue?: () => Promise<string>;\n  disabled?: boolean;\n  className?: string;\n};\n\nexport function CopyButton({\n  value,\n  label = \"Copy\",\n  copiedLabel = \"Copied\",\n  errorLabel = \"Failed\",\n  timeout = 2000,\n  onCopy,\n  onError,\n  onIntent,\n  resolveValue,\n  disabled = false,\n  className = \"\",\n}: CopyButtonProps) {\n  const { copy, fail, status } = useCopyToClipboard({ timeout, onCopy, onError });\n  const reduced = useReducedMotion();\n\n  const fade = reduced ? INSTANT : CROSSFADE;\n  const draw = reduced ? INSTANT : DRAW;\n\n  const labels: Array<[CopyStatus, string]> = [\n    [\"idle\", label],\n    [\"copied\", copiedLabel],\n    [\"error\", errorLabel],\n  ];\n\n  return (\n    <motion.button\n      type=\"button\"\n      disabled={disabled}\n      aria-label={label}\n      onFocus={onIntent}\n      onPointerEnter={onIntent}\n      onClick={() => {\n        void (async () => {\n          try {\n            const resolvedValue = value || await resolveValue?.() || \"\";\n            await copy(resolvedValue);\n          } catch (error) {\n            fail(error);\n          }\n        })();\n      }}\n      whileTap={disabled || reduced ? undefined : { y: 1 }}\n      transition={CELL}\n      style={{ borderRadius: 9, touchAction: \"manipulation\" }}\n      className={`inline-flex h-11 select-none items-center gap-2 rounded-[9px] border border-stone-200 bg-white px-3 text-[13px] font-medium text-stone-700 shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),inset_0_-1px_0_rgba(28,25,23,0.06),0_1px_2px_rgba(28,25,23,0.08)] outline-none transition-[border-color,box-shadow,background-color] duration-150 hover:bg-stone-50 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] disabled:opacity-50 dark:border-white/[0.16] dark:bg-[#252522] dark:text-stone-200 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:bg-[#2A2A27] dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)] ${className}`}\n    >\n      <span className=\"grid size-[14px] shrink-0\" aria-hidden=\"true\">\n        <motion.svg\n          viewBox=\"0 0 14 14\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          className=\"col-start-1 row-start-1 size-[14px]\"\n          initial={false}\n          animate={{\n            opacity: status === \"idle\" ? 1 : 0,\n            scale: status === \"idle\" ? 1 : 0.92,\n          }}\n          transition={fade}\n        >\n          <path d=\"M9.6 5.1V3.7A1.7 1.7 0 0 0 7.9 2H3.7A1.7 1.7 0 0 0 2 3.7v4.2a1.7 1.7 0 0 0 1.7 1.7h1.4\" />\n          <rect x=\"5.1\" y=\"5.1\" width=\"6.9\" height=\"6.9\" rx=\"1.7\" />\n        </motion.svg>\n\n        <motion.svg\n          viewBox=\"0 0 14 14\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          className=\"col-start-1 row-start-1 size-[14px]\"\n          initial={false}\n          animate={{\n            opacity: status === \"copied\" ? 1 : 0,\n            scale: status === \"copied\" ? 1 : 0.92,\n          }}\n          transition={fade}\n        >\n          <motion.path\n            d=\"M2.9 7.4 5.6 10.1 11.1 4\"\n            initial={false}\n            animate={{ pathLength: status === \"copied\" ? 1 : 0 }}\n            transition={draw}\n          />\n        </motion.svg>\n\n        <motion.svg\n          viewBox=\"0 0 14 14\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          className=\"col-start-1 row-start-1 size-[14px]\"\n          initial={false}\n          animate={{\n            opacity: status === \"error\" ? 1 : 0,\n            scale: status === \"error\" ? 1 : 0.92,\n          }}\n          transition={fade}\n        >\n          <path d=\"M3.6 3.6 10.4 10.4\" />\n          <path d=\"M10.4 3.6 3.6 10.4\" />\n        </motion.svg>\n      </span>\n\n      <span aria-hidden=\"true\" className=\"relative grid\">\n        {labels.map(([key, text]) => (\n          <motion.span\n            key={key}\n            initial={false}\n            animate={\n              key === status\n                ? { opacity: 1, y: 0, filter: \"blur(0px)\" }\n                : { opacity: 0, y: 3, filter: \"blur(3px)\" }\n            }\n            transition={fade}\n            className=\"col-start-1 row-start-1 whitespace-nowrap\"\n          >\n            {text}\n          </motion.span>\n        ))}\n      </span>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {status === \"copied\" ? copiedLabel : status === \"error\" ? errorLabel : \"\"}\n      </span>\n    </motion.button>\n  );\n}\n"
    }
  ]
}
