{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "copy-button",
  "type": "registry:ui",
  "title": "Copy Button",
  "description": "An accessible React copy to clipboard button for Next.js: the copy icon turns into a check, and screen readers hear \"Copied\".",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "lucide-react@^1.52.0",
    "motion@^14.0.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json",
    "https://ui.fewtone.com/r/button.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/copy-button.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { Button, type ButtonProps } from \"@/components/ui/button\";\nimport { duration, ease, spring, swap } from \"@/lib/motion\";\nimport { CopyIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type MouseEvent, type ReactNode, useEffect, useRef, useState } from \"react\";\n\n/*\n * A Button that copies text to the clipboard (owner, 2026-10-14).\n * - Motion (.agents/rules/motion.md): the same swap as Loading Button, a blur with a 6px rise. The copy\n *   icon lifts away as a check rises in and draws itself. With reduced motion it only fades.\n * - Width never jumps: both states are laid out invisibly in one cell, so a label can change\n *   (\"Copy link\" → \"Copied\") without the button resizing.\n * - Icon only by default (ghost, icon size), named \"Copy\"; pass children for a label, and `icon={false}`\n *   for text only (the label still swaps through the blur).\n * - A polite live region says \"Copied\". Back to the copy icon after `resetAfter`.\n * - Uses the Clipboard API, falling back to a hidden textarea where it's unavailable (older browsers,\n *   pages not served over https).\n */\n\nexport type CopyButtonProps = Omit<ButtonProps, \"value\" | \"asChild\"> & {\n  /** The text to copy */\n  value: string;\n  /** Shown instead of the label once copied, for example \"Copied\" */\n  copiedText?: ReactNode;\n  /** Called with the text after it's copied */\n  onCopy?: (value: string) => void;\n  /** Milliseconds the check shows before the copy icon returns */\n  resetAfter?: number;\n  /** Show the copy icon and the check. Set false for a text-only button; it then needs a label. */\n  icon?: boolean;\n};\n\nconst CHECK = \"M5 12.5l4.5 4.5L19 7.5\";\n\n/** The check draws itself in; a 16px icon stroke, so drawing it costs nothing */\nfunction Check({ reduce }: { reduce: boolean }) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden>\n      <motion.path\n        d={CHECK}\n        stroke=\"currentColor\"\n        strokeWidth=\"2.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        initial={{ pathLength: reduce ? 1 : 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: duration.slow, ease: ease.out, delay: reduce ? 0 : duration.instant }}\n      />\n    </svg>\n  );\n}\n\n/** The Clipboard API, or a hidden textarea where it's unavailable */\nasync function writeText(text: string) {\n  if (navigator.clipboard?.writeText) {\n    await navigator.clipboard.writeText(text);\n    return;\n  }\n  const area = document.createElement(\"textarea\");\n  area.value = text;\n  area.setAttribute(\"readonly\", \"\");\n  area.style.position = \"fixed\";\n  area.style.opacity = \"0\";\n  document.body.appendChild(area);\n  area.select();\n  const copied = document.execCommand(\"copy\");\n  area.remove();\n  if (!copied) throw new Error(\"Copy failed\");\n}\n\nfunction CopyButton({\n  value,\n  copiedText,\n  onCopy,\n  resetAfter = 2000,\n  icon = true,\n  onClick,\n  children,\n  variant = \"ghost\",\n  size,\n  ...props\n}: CopyButtonProps) {\n  const [copied, setCopied] = useState(false);\n  // Counts copies, so copying again while the check shows starts its time over\n  const [copies, setCopies] = useState(0);\n  const reduce = useReducedMotion() ?? false;\n  const mounted = useRef(true);\n\n  useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n    };\n  }, []);\n\n  // The check gives way to the copy icon after a moment, counted from the latest copy\n  // biome-ignore lint/correctness/useExhaustiveDependencies: each copy restarts the timer\n  useEffect(() => {\n    if (!copied) return;\n    const timer = setTimeout(() => setCopied(false), resetAfter);\n    return () => clearTimeout(timer);\n  }, [copied, copies, resetAfter]);\n\n  const content = (state: \"idle\" | \"copied\", live: boolean) => {\n    const glyph = !icon ? null : live ? (\n      state === \"copied\" ? (\n        <Check reduce={reduce} />\n      ) : (\n        <CopyIcon aria-hidden />\n      )\n    ) : (\n      <span className=\"size-(--size-icon-md) shrink-0\" />\n    );\n    const label = state === \"copied\" ? (copiedText ?? children) : children;\n    return (\n      <>\n        {glyph}\n        {label}\n      </>\n    );\n  };\n\n  return (\n    <>\n      <Button\n        data-slot=\"copy-button\"\n        data-state={copied ? \"copied\" : \"idle\"}\n        variant={variant}\n        size={size ?? (children ? \"md\" : \"icon\")}\n        aria-label={children ? undefined : \"Copy\"}\n        onClick={async (event: MouseEvent<HTMLButtonElement>) => {\n          onClick?.(event);\n          if (event.defaultPrevented) return;\n          try {\n            await writeText(value);\n            if (!mounted.current) return;\n            setCopied(true);\n            setCopies((count) => count + 1);\n            onCopy?.(value);\n          } catch {\n            // The clipboard can be blocked; the text is still on the page to select\n          }\n        }}\n        {...props}\n      >\n        <span className=\"grid\">\n          {/* Both states, invisible, in one cell: the button is as wide as the wider */}\n          {([\"idle\", \"copied\"] as const).map((state) => (\n            <span\n              key={state}\n              aria-hidden\n              className=\"invisible col-start-1 row-start-1 flex items-center justify-center gap-2\"\n            >\n              {content(state, false)}\n            </span>\n          ))}\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={copied ? \"copied\" : \"idle\"}\n              {...(reduce\n                ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }\n                : swap)}\n              transition={\n                reduce\n                  ? { duration: duration.fast }\n                  : { duration: duration.base, ease: ease.out, y: spring.snappy }\n              }\n              className=\"col-start-1 row-start-1 flex items-center justify-center gap-2\"\n            >\n              {content(copied ? \"copied\" : \"idle\", true)}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </Button>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {copied ? \"Copied\" : \"\"}\n      </span>\n    </>\n  );\n}\n\nexport { CopyButton };\n"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/copy-button"
}
