{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "copy-button",
  "type": "registry:ui",
  "title": "Copy Button",
  "description": "Copies on press, draws a tick, holds its width and reverts on its own.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/copy-button.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\ntype CopyState = \"idle\" | \"copied\" | \"failed\";\ntype Value = string | (() => string | Promise<string>);\n\nasync function writeText(text: string) {\n  if (navigator.clipboard?.writeText && window.isSecureContext) return navigator.clipboard.writeText(text);\n  // Insecure contexts (plain http, some embeds) have no async clipboard.\n  const area = document.createElement(\"textarea\");\n  area.value = text;\n  area.setAttribute(\"readonly\", \"\");\n  area.style.cssText = \"position:fixed;top:0;left:0;opacity:0;pointer-events:none\";\n  document.body.appendChild(area);\n  area.select();\n  const ok = document.execCommand(\"copy\");\n  area.remove();\n  if (!ok) throw new Error(\"The browser refused the copy command\");\n}\n\n/** The copy lifecycle on its own, for when the trigger isn't a button. */\nexport function useCopy({ timeout = 2000, onCopied, onError }: { timeout?: number; onCopied?: (text: string) => void; onError?: (error: unknown) => void } = {}) {\n  const [state, setState] = useState<CopyState>(\"idle\");\n  const timer = useRef<number>(undefined);\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  const copy = useCallback(\n    async (value: Value) => {\n      window.clearTimeout(timer.current);\n      try {\n        const text = typeof value === \"function\" ? await value() : value;\n        // Never report a success that didn't happen.\n        if (!text) throw new Error(\"Nothing to copy\");\n        await writeText(text);\n        setState(\"copied\");\n        onCopied?.(text);\n      } catch (error) {\n        setState(\"failed\");\n        onError?.(error);\n      }\n      timer.current = window.setTimeout(() => setState(\"idle\"), timeout);\n    },\n    [timeout, onCopied, onError],\n  );\n\n  return { state, copy };\n}\n\nexport type CopyButtonProps = Omit<React.ComponentProps<\"button\">, \"value\" | \"children\" | \"onCopy\"> & {\n  /** The text to copy, or a function that returns it (sync or async) at press time. */\n  value: Value;\n  label?: string;\n  copiedLabel?: string;\n  failedLabel?: string;\n  /** Square button with only the icon. The label becomes the accessible name. */\n  iconOnly?: boolean;\n  size?: \"sm\" | \"md\";\n  variant?: \"secondary\" | \"ghost\";\n  /** Milliseconds before the button returns to its resting state. */\n  timeout?: number;\n  onCopied?: (text: string) => void;\n  onError?: (error: unknown) => void;\n};\n\nexport function CopyButton({\n  value,\n  label = \"Copy\",\n  copiedLabel = \"Copied\",\n  failedLabel = \"Failed\",\n  iconOnly = false,\n  size = \"md\",\n  variant = \"secondary\",\n  timeout = 2000,\n  onCopied,\n  onError,\n  disabled,\n  className,\n  onClick,\n  ...rest\n}: CopyButtonProps) {\n  const { state, copy } = useCopy({ timeout, onCopied, onError });\n  const reduce = useReducedMotion();\n  const empty = value === \"\";\n  const text = state === \"copied\" ? copiedLabel : state === \"failed\" ? failedLabel : label;\n\n  // Swaps pop in place. Reduced motion keeps the crossfade and drops the travel.\n  const enter = reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" };\n  const leave = reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" };\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        data-state={state}\n        data-size={size}\n        data-variant={variant}\n        disabled={disabled || empty}\n        aria-label={iconOnly ? text : undefined}\n        onClick={(e) => {\n          onClick?.(e);\n          if (!e.defaultPrevented) copy(value);\n        }}\n        className={cn(\n          \"group/copy relative inline-flex shrink-0 select-none items-center justify-center font-medium tracking-[-0.005em]\",\n          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n          \"disabled:pointer-events-none disabled:opacity-50\",\n          size === \"sm\" ? \"h-7 gap-1.5 rounded-md text-[12px]\" : \"h-8 gap-2 rounded-lg text-[12.5px]\",\n          iconOnly ? (size === \"sm\" ? \"w-7\" : \"w-8\") : size === \"sm\" ? \"px-2\" : \"px-2.5\",\n          // Icon-only buttons draw at 28–32px; on touch the hit area grows to 44px+ without changing the drawing.\n          iconOnly && \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n          variant === \"secondary\"\n            ? \"border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover\"\n            : \"text-fg-2 hover:bg-hover hover:text-fg\",\n          state === \"failed\" && \"text-danger hover:text-danger\",\n          className,\n        )}\n        {...rest}\n      >\n        <span className=\"relative grid size-4 place-items-center\">\n          <AnimatePresence initial={false}>\n            <motion.span\n              key={state}\n              className=\"absolute inset-0 grid place-items-center\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n              transition={reduce ? { duration: 0.15 } : spring.pop}\n            >\n              <Glyph state={state} reduce={!!reduce} />\n            </motion.span>\n          </AnimatePresence>\n        </span>\n\n        {!iconOnly && (\n          // Every label sits in the same grid cell, so the button is always as wide\n          // as the longest one and never shifts the layout around it.\n          <span className=\"grid overflow-hidden py-1 text-left\">\n            {[label, copiedLabel, failedLabel].map((l, i) => (\n              <span key={i} aria-hidden className=\"invisible col-start-1 row-start-1\">{l}</span>\n            ))}\n            <AnimatePresence initial={false}>\n              <motion.span\n                key={state}\n                className=\"col-start-1 row-start-1\"\n                initial={enter}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={leave}\n                transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n              >\n                {text}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        )}\n      </button>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {state === \"copied\" ? copiedLabel : state === \"failed\" ? failedLabel : \"\"}\n      </span>\n    </>\n  );\n}\n\nfunction Glyph({ state, reduce }: { state: CopyState; reduce: boolean }) {\n  const draw = reduce ? {} : { initial: { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration: 0.32, ease: ease.out, delay: 0.04 } };\n  const common = { width: 16, height: 16, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.4, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n  if (state === \"copied\")\n    return (\n      <svg {...common}>\n        <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" {...draw} />\n      </svg>\n    );\n  if (state === \"failed\")\n    return (\n      <svg {...common}>\n        <motion.path d=\"m4.5 4.5 7 7\" {...draw} />\n        <motion.path d=\"m11.5 4.5-7 7\" {...draw} transition={{ ...draw.transition, delay: 0.12 }} />\n      </svg>\n    );\n  return (\n    <svg {...common} className=\"transition-transform duration-200 ease-out group-hover/copy:-translate-y-px\">\n      <rect x=\"5.5\" y=\"5.5\" width=\"7.5\" height=\"7.5\" rx=\"1.75\" />\n      <path d=\"M10.5 5.5V4.25A1.25 1.25 0 0 0 9.25 3h-5A1.25 1.25 0 0 0 3 4.25v5A1.25 1.25 0 0 0 4.25 10.5H5.5\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/copy-button"
}
