{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "copy-button",
  "type": "registry:ui",
  "title": "Copy Button",
  "description": "A copy-to-clipboard button whose icon morphs into a drawn check, with a tooltip, live announcement and fallback.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/copy-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\ntype CopyStatus = \"idle\" | \"copied\" | \"error\";\n\nexport interface CopyButtonProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"button\">,\n    \"value\" | \"children\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\" | \"onCopy\"\n  > {\n  /** Text to copy. */\n  value?: string;\n  /** Resolves the text at click time instead of `value` (may be async). */\n  getValue?: () => string | Promise<string>;\n  /** How long the copied / error state is shown, in ms. */\n  timeout?: number;\n  /** Action label: the accessible name of the icon variant, the pill text and the idle tooltip. */\n  label?: string;\n  /** Shown and announced after a successful copy. */\n  copiedLabel?: string;\n  /** Shown and announced when the clipboard is unavailable. */\n  errorLabel?: string;\n  /** Icon-only button, an inline chip that shows the value, or a pill with a label. */\n  variant?: \"icon\" | \"inline\" | \"pill\";\n  /** Show a small tooltip above the icon and inline variants. */\n  tooltip?: boolean;\n  /** Check mark and highlight colour. */\n  accent?: string;\n  /** Inline variant only: content to display instead of `value`. */\n  children?: React.ReactNode;\n  /** Called with the copied text. */\n  onCopy?: (text: string) => void;\n  /** Called when copying fails. */\n  onCopyError?: (error: unknown) => void;\n}\n\nconst EASE = [0.22, 1, 0.36, 1] as const;\n\n/** Clipboard API first; a hidden textarea + execCommand fallback for insecure contexts. */\nasync function writeToClipboard(text: string) {\n  if (typeof navigator !== \"undefined\" && navigator.clipboard && window.isSecureContext) {\n    try {\n      await navigator.clipboard.writeText(text);\n      return;\n    } catch {\n      // Permission denied or document not focused: try the legacy path below.\n    }\n  }\n  const previousFocus = document.activeElement as HTMLElement | null;\n  const textarea = document.createElement(\"textarea\");\n  textarea.value = text;\n  textarea.setAttribute(\"readonly\", \"\");\n  textarea.setAttribute(\"aria-hidden\", \"true\");\n  Object.assign(textarea.style, { position: \"fixed\", top: \"0\", left: \"0\", opacity: \"0\", pointerEvents: \"none\" });\n  document.body.appendChild(textarea);\n  textarea.select();\n  let ok = false;\n  try {\n    ok = document.execCommand(\"copy\");\n  } finally {\n    textarea.remove();\n    previousFocus?.focus({ preventScroll: true });\n  }\n  if (!ok) throw new Error(\"Clipboard unavailable\");\n}\n\nfunction Glyphs({ status, instant }: { status: CopyStatus; instant: boolean }) {\n  const t = instant ? { duration: 0 } : { duration: 0.28, ease: EASE };\n  const draw = (on: boolean, delay = 0) => ({\n    initial: false as const,\n    animate: { pathLength: on ? 1 : 0, opacity: on ? 1 : 0 },\n    transition: instant ? { duration: 0 } : { duration: on ? 0.32 : 0.12, delay: on ? 0.08 + delay : 0, ease: EASE },\n  });\n  return (\n    <span aria-hidden=\"true\" className=\"grid size-4 shrink-0 place-items-center [&>*]:col-start-1 [&>*]:row-start-1\">\n      <motion.svg\n        viewBox=\"0 0 16 16\"\n        fill=\"none\"\n        className=\"size-4\"\n        initial={false}\n        animate={status === \"idle\" ? { opacity: 1, scale: 1, rotate: 0 } : { opacity: 0, scale: 0.6, rotate: -12 }}\n        transition={t}\n      >\n        <rect x=\"5.25\" y=\"5.25\" width=\"8\" height=\"8\" rx=\"1.75\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n        <path d=\"M10.75 3.25v-.5a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h.5\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" />\n      </motion.svg>\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4 text-[color-mix(in_oklab,var(--cb-accent)_80%,currentColor)]\">\n        <motion.path d=\"M3.5 8.5l3 3 6-7\" stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" strokeLinejoin=\"round\" {...draw(status === \"copied\")} />\n      </svg>\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4 text-red-500 dark:text-red-400\">\n        <motion.path d=\"M4.75 4.75l6.5 6.5\" stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" {...draw(status === \"error\")} />\n        <motion.path d=\"M11.25 4.75l-6.5 6.5\" stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" {...draw(status === \"error\", 0.1)} />\n      </svg>\n    </span>\n  );\n}\n\n/**\n * Copies text to the clipboard and confirms it: the clipboard icon crossfades into a check that\n * draws itself in, a tooltip says \"Copied\", and a live region announces it. Falls back to\n * execCommand where the Clipboard API is unavailable and shows an error state if both fail.\n */\nexport const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(function CopyButton(\n  {\n    value,\n    getValue,\n    timeout = 1600,\n    label = \"Copy\",\n    copiedLabel = \"Copied\",\n    errorLabel = \"Copy failed\",\n    variant = \"icon\",\n    tooltip = true,\n    accent = \"#FF7448\",\n    children,\n    onCopy,\n    onCopyError,\n    className = \"\",\n    style,\n    onClick,\n    ...props\n  },\n  ref,\n) {\n  const reduceMotion = !!useReducedMotion();\n  const [status, setStatus] = React.useState<CopyStatus>(\"idle\");\n  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n\n  React.useEffect(() => () => clearTimeout(timer.current), []);\n\n  const show = (next: CopyStatus) => {\n    clearTimeout(timer.current);\n    setStatus(next);\n    timer.current = setTimeout(() => setStatus(\"idle\"), Math.max(timeout, 0));\n  };\n\n  const copy = async () => {\n    try {\n      const text = getValue ? await getValue() : (value ?? \"\");\n      await writeToClipboard(text);\n      show(\"copied\");\n      onCopy?.(text);\n    } catch (error) {\n      show(\"error\");\n      onCopyError?.(error);\n    }\n  };\n\n  const stateLabel = status === \"copied\" ? copiedLabel : status === \"error\" ? errorLabel : label;\n  const showTooltip = tooltip && variant !== \"pill\";\n\n  const shape =\n    variant === \"icon\"\n      ? \"size-9 justify-center rounded-lg\"\n      : variant === \"inline\"\n        ? \"h-9 max-w-full gap-3 rounded-lg pl-3 pr-2.5 font-mono text-[13px]\"\n        : \"h-9 gap-2 rounded-full px-4 text-sm font-medium tracking-tight\";\n\n  const tone =\n    status === \"copied\"\n      ? \"border-[color-mix(in_oklab,var(--cb-accent)_55%,transparent)]\"\n      : status === \"error\"\n        ? \"border-red-500/50\"\n        : \"border-zinc-900/10 hover:border-zinc-900/25 dark:border-white/10 dark:hover:border-white/25\";\n\n  return (\n    <span\n      className=\"group/cb relative inline-flex max-w-full\"\n      data-status={status}\n      style={{ [\"--cb-accent\" as string]: accent }}\n    >\n      <button\n        ref={ref}\n        type=\"button\"\n        aria-label={variant === \"icon\" ? label : undefined}\n        onClick={(event) => {\n          onClick?.(event);\n          if (!event.defaultPrevented) void copy();\n        }}\n        className={`inline-flex min-w-0 select-none items-center border bg-white/70 text-zinc-700 transition-[border-color,background-color,color] duration-200 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--cb-accent)] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white/[0.04] dark:text-zinc-300 dark:hover:text-white ${shape} ${tone} ${className}`}\n        style={style}\n        {...props}\n      >\n        {variant === \"inline\" && (\n          <span className=\"min-w-0 truncate text-zinc-900 dark:text-zinc-100\">\n            <span className=\"sr-only\">{label}: </span>\n            {children ?? value}\n          </span>\n        )}\n        <Glyphs status={status} instant={reduceMotion} />\n        {variant === \"pill\" && (\n          <span className=\"relative inline-grid overflow-hidden [&>*]:col-start-1 [&>*]:row-start-1\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={status}\n                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}\n                transition={{ duration: reduceMotion ? 0.1 : 0.22, ease: EASE }}\n              >\n                {stateLabel}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        )}\n      </button>\n\n      {showTooltip && (\n        <span\n          aria-hidden=\"true\"\n          className={`pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white transition-[opacity,translate] duration-200 motion-reduce:transition-none dark:bg-white dark:text-zinc-900 ${\n            status === \"idle\"\n              ? \"translate-y-1 opacity-0 group-hover/cb:translate-y-0 group-hover/cb:opacity-100 group-has-[:focus-visible]/cb:translate-y-0 group-has-[:focus-visible]/cb:opacity-100\"\n              : \"translate-y-0 opacity-100\"\n          }`}\n        >\n          {stateLabel}\n        </span>\n      )}\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {status === \"copied\" ? copiedLabel : status === \"error\" ? errorLabel : \"\"}\n      </span>\n    </span>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/copy-button"
}
