{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "copy-button",
  "type": "registry:ui",
  "title": "Copy button",
  "description": "Copy a value with immediate confirmation.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/copy-button/copy-button.tsx",
      "type": "registry:component",
      "target": "@components/xzero/copy-button/copy-button.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { CircleAlert, Copy } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { useCopyFeedback } from \"../lib/use-copy-feedback\";\nimport styles from \"./copy-button.module.css\";\n\nexport interface CopyButtonProps {\n  value: string;\n  label?: string;\n  className?: string;\n  iconOnly?: boolean;\n  variant?: \"outline\" | \"plain\";\n  disabled?: boolean;\n  onCopied?: () => void;\n}\n\n/** Copy feedback is quick and quiet: the old icon and word fade out fast while the new ones fade in, with no blur and no\n *  per-letter motion. The button eases to the width of the current word on a spring with no overshoot, so a long label\n *  like \"Copy model id\" never leaves empty space next to \"Copied\", and nothing jumps. */\nconst ease = [...motionTokens.ease.standard] as [number, number, number, number];\nconst show = { duration: .16, ease } as const;\nconst hide = { duration: .1, ease } as const;\nconst instant = { duration: 0 } as const;\nconst resize = { type: \"spring\", visualDuration: .22, bounce: 0 } as const;\n\n/** The success tick draws itself in one short stroke. */\nfunction DrawnCheck({ reduced }: { reduced: boolean }) {\n  return <svg width={16} height={16} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.75} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n    <motion.path d=\"M4 12l5 5L20 6\" initial={reduced ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: .22, ease, delay: .04 }} />\n  </svg>;\n}\n\nexport function CopyButton({ value, label = \"Copy\", className, iconOnly = false, variant = \"outline\", disabled, onCopied }: CopyButtonProps) {\n  const { state, copy } = useCopyFeedback();\n  const reduced = useReducedMotion() ?? false;\n  const text = state === \"copied\" ? \"Copied\" : state === \"error\" ? \"Failed\" : label;\n  // Each word's natural width, measured off screen (and again when fonts load or the label changes).\n  const measures = useRef<Record<string, HTMLSpanElement | null>>({});\n  const [widths, setWidths] = useState<Record<string, number>>({});\n  useLayoutEffect(() => {\n    if (iconOnly) return;\n    const read = () => setWidths(Object.fromEntries(Object.entries(measures.current).map(([key, node]) => [key, node?.offsetWidth ?? 0])));\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    Object.values(measures.current).forEach(node => { if (node) observer.observe(node); });\n    return () => observer.disconnect();\n  }, [label, iconOnly]);\n  const width = widths[state === \"copied\" ? \"copied\" : state === \"error\" ? \"error\" : \"idle\"];\n  // The first measured width applies at once; only later changes ease, so the very first copy animates too.\n  const [shown, setShown] = useState<number | undefined>(undefined);\n  const first = shown === undefined;\n  if (width !== undefined && width !== shown) setShown(width);\n\n  async function handleCopy() {\n    if (await copy(value)) onCopied?.();\n  }\n\n  return <><button\n    type=\"button\"\n    className={[styles.button, iconOnly && styles.iconOnly, variant === \"plain\" && styles.plain, className].filter(Boolean).join(\" \")}\n    onClick={() => void handleCopy()}\n    aria-label={label}\n    data-copy-state={state}\n    disabled={disabled}\n  >\n    <span className={styles.icon} aria-hidden=\"true\">\n      <AnimatePresence initial={false}>\n        <motion.span key={state} className={styles.iconInner} data-state={state}\n          initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .85 }} animate={{ opacity: 1, scale: 1 }}\n          exit={{ opacity: 0, transition: reduced ? instant : hide }} transition={reduced ? instant : show}>\n          {state === \"copied\" ? <DrawnCheck reduced={reduced} /> : state === \"error\" ? <CircleAlert size={16} strokeWidth={1.75} /> : <Copy size={16} strokeWidth={1.75} />}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n    {!iconOnly && <motion.span className={styles.label} aria-hidden=\"true\" initial={false} animate={width ? { width } : undefined} transition={reduced || first ? instant : resize}>\n      <span ref={node => { measures.current.idle = node; }} className={styles.measure}>{label}</span>\n      <span ref={node => { measures.current.copied = node; }} className={styles.measure}>Copied</span>\n      <span ref={node => { measures.current.error = node; }} className={styles.measure}>Failed</span>\n      <AnimatePresence initial={false}>\n        <motion.span key={text} className={styles.text} initial={reduced ? { opacity: 0 } : { opacity: 0, y: 2 }} animate={{ opacity: 1, y: 0 }}\n          exit={{ opacity: 0, transition: reduced ? instant : hide }} transition={reduced ? instant : show}>{text}</motion.span>\n      </AnimatePresence>\n    </motion.span>}\n  </button><span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{state === \"idle\" ? \"\" : state === \"error\" ? `${label}: Could not copy` : `${label}: Copied`}</span></>;\n}\n"
    },
    {
      "path": "registry/components/copy-button/copy-button.module.css",
      "type": "registry:component",
      "target": "@components/xzero/copy-button/copy-button.module.css",
      "content": "/* The label cell eases to the current word's width (no overshoot) while icon and word crossfade inside it, centered. Press releases on the spring curve. */\n.button { display: inline-flex; width: max-content; max-width: 100%; min-height: var(--control-height-sm); align-items: center; justify-content: center; gap: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-control); padding: 0 var(--space-3); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.button:active:not(:disabled) { transform: scale(.97); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), var(--duration-instant); transition-timing-function: var(--ease-standard); }\n.iconOnly:active:not(:disabled) { transform: scale(.96); }\n@media (hover: hover) and (pointer: fine) { .button:hover:not(:disabled) { background: var(--surface-muted); border-color: var(--border-strong); } .plain:hover:not(:disabled) { border-color: transparent; background: var(--surface-muted); color: var(--foreground); } }\n.button:disabled { cursor: not-allowed; opacity: .5; }\n/* Each icon carries its own color, so the outgoing glyph never tints on its way out. */\n.icon { position: relative; display: grid; width: 16px; height: 16px; flex: none; }\n.iconInner { position: absolute; inset: 0; display: grid; place-items: center; }\n.iconInner[data-state=\"copied\"] { color: var(--success); }\n.iconInner[data-state=\"error\"] { color: var(--danger); }\n.label { position: relative; display: grid; min-width: 0; justify-items: center; white-space: nowrap; }\n.text { grid-area: 1 / 1; }\n/* Off-screen copies of each word, so every width is known before it is shown. */\n.measure { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }\n.iconOnly { width: var(--control-height-sm); padding: 0; }\n.plain { border-color: transparent; background: transparent; color: var(--text-secondary); }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .button { transition: none; } .button:active:not(:disabled) { transform: none; } }\n"
    },
    {
      "path": "lib/use-copy-feedback.ts",
      "type": "registry:lib",
      "target": "@components/xzero/lib/use-copy-feedback.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport type CopyFeedbackState = \"idle\" | \"copied\" | \"error\";\n\n/**\n * Writes text to the clipboard. Uses the async Clipboard API and falls back to a hidden textarea with\n * document.execCommand(\"copy\") when the API is missing or refused (sandboxed or cross-origin iframes, Safari\n * inside embedded previews, insecure contexts). Must be called from a user gesture. Rejects only when both fail.\n */\nexport async function writeClipboard(text: string): Promise<void> {\n  try {\n    if (navigator.clipboard?.writeText) {\n      await navigator.clipboard.writeText(text);\n      return;\n    }\n  } catch {\n    // Fall through to the legacy path below.\n  }\n  const active = document.activeElement as HTMLElement | null;\n  const selection = document.getSelection();\n  const ranges = selection ? Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index)) : [];\n  const area = document.createElement(\"textarea\");\n  area.value = text;\n  area.setAttribute(\"readonly\", \"\");\n  area.style.cssText = \"position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none;\";\n  document.body.appendChild(area);\n  area.select();\n  area.setSelectionRange(0, text.length);\n  let ok = false;\n  try { ok = document.execCommand(\"copy\"); } catch { ok = false; }\n  area.remove();\n  if (selection) { selection.removeAllRanges(); ranges.forEach(range => selection.addRange(range)); }\n  active?.focus?.({ preventScroll: true });\n  if (!ok) throw new Error(\"Copy failed\");\n}\n\n/** Shared clipboard state for actions that render their own button or menu. */\nexport function useCopyFeedback(duration = 1900) {\n  const [state, setState] = useState<CopyFeedbackState>(\"idle\");\n  const [activeKey, setActiveKey] = useState<string | null>(null);\n  const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const reset = useCallback(() => {\n    if (timeout.current) clearTimeout(timeout.current);\n    timeout.current = null;\n    setState(\"idle\");\n    setActiveKey(null);\n  }, []);\n\n  useEffect(() => () => {\n    if (timeout.current) clearTimeout(timeout.current);\n  }, []);\n\n  const copy = useCallback(async (value: string, key = \"default\") => {\n    if (timeout.current) clearTimeout(timeout.current);\n    setActiveKey(key);\n    try {\n      await writeClipboard(value);\n      setState(\"copied\");\n      timeout.current = setTimeout(reset, duration);\n      return true;\n    } catch {\n      setState(\"error\");\n      timeout.current = setTimeout(reset, duration);\n      return false;\n    }\n  }, [duration, reset]);\n\n  return { state, activeKey, copy, reset };\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/copy-button",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "action",
      "utility"
    ],
    "docs": "https://ui.xzero.co.uk/components/copy-button",
    "markdown": "https://ui.xzero.co.uk/components/copy-button/markdown"
  }
}