{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"copy-button","type":"registry:component","title":"Copy button","description":"One-click clipboard copy action with checkmark icon morph, label crossfade, and self-reverting timer.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/copy-button.tsx","type":"registry:component","content":"// Hairline UI · copy-button · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Copy } from \"lucide-react\";\nimport { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS } from \"./ui-interaction-motion\";\n\nexport interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  /** The text string to copy to the clipboard. */\n  text: string;\n  /** Label shown when idle. */\n  label?: string;\n  /** Label shown when copied successfully. */\n  copiedLabel?: string;\n  /** Whether to show text label beside the icon. Defaults to false (icon-only). */\n  showLabel?: boolean;\n  /** Milliseconds before reverting back to idle. Default 2000ms. */\n  timeoutMs?: number;\n  variant?: \"secondary\" | \"outline\" | \"ghost\";\n  size?: \"xs\" | \"sm\" | \"md\";\n  onCopySuccess?: (text: string) => void;\n  onCopyError?: (error: unknown) => void;\n}\n\nconst sizeStyles = {\n  xs: {\n    btn: \"h-6 text-[11px] rounded-full gap-1.5 px-2.5\",\n    iconOnly: \"size-6 p-0 rounded-full\",\n    icon: 11,\n  },\n  sm: {\n    btn: \"h-7 text-[12px] rounded-full gap-1.5 px-3\",\n    iconOnly: \"size-7 p-0 rounded-full\",\n    icon: 13,\n  },\n  md: {\n    btn: \"h-9 text-[13px] rounded-full gap-2 px-4\",\n    iconOnly: \"size-9 p-0 rounded-full\",\n    icon: 15,\n  },\n};\n\nconst variantStyles = {\n  secondary:\n    \"bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent active:scale-[0.97]\",\n  outline:\n    \"bg-transparent text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent active:scale-[0.97]\",\n  ghost:\n    \"bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground active:scale-[0.97]\",\n};\n\nfunction AnimatedCheck({ drawn, size }: { drawn: boolean; size: number }) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      className=\"text-emerald-600 dark:text-emerald-400 shrink-0\"\n    >\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{\n          strokeDashoffset: drawn ? 0 : 1,\n          transition: drawn ? `stroke-dashoffset 300ms ${EASE_SETTLE} 40ms` : \"none\",\n        }}\n      />\n    </svg>\n  );\n}\n\nexport function CopyButton({\n  text,\n  label = \"Copy\",\n  copiedLabel = \"Copied!\",\n  showLabel = false,\n  timeoutMs = 2000,\n  variant = \"ghost\",\n  size = \"sm\",\n  onCopySuccess,\n  onCopyError,\n  className = \"\",\n  disabled,\n  onClick,\n  ...props\n}: CopyButtonProps) {\n  const [copied, setCopied] = React.useState(false);\n  const [pressed, setPressed] = React.useState(false);\n  const timerRef = React.useRef<number | null>(null);\n\n  React.useEffect(() => {\n    return () => {\n      if (timerRef.current) window.clearTimeout(timerRef.current);\n    };\n  }, []);\n\n  const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {\n    onClick?.(e);\n    if (disabled || !text) return;\n\n    try {\n      if (navigator?.clipboard?.writeText) {\n        await navigator.clipboard.writeText(text);\n      } else {\n        // Fallback for non-secure contexts\n        const textarea = document.createElement(\"textarea\");\n        textarea.value = text;\n        textarea.style.position = \"fixed\";\n        textarea.style.opacity = \"0\";\n        document.body.appendChild(textarea);\n        textarea.select();\n        document.execCommand(\"copy\");\n        document.body.removeChild(textarea);\n      }\n\n      setCopied(true);\n      onCopySuccess?.(text);\n\n      if (timerRef.current) window.clearTimeout(timerRef.current);\n      timerRef.current = window.setTimeout(() => {\n        setCopied(false);\n      }, timeoutMs);\n    } catch (err) {\n      onCopyError?.(err);\n    }\n  };\n\n  const s = sizeStyles[size];\n  const v = variantStyles[variant];\n\n  return (\n    <button\n      type=\"button\"\n      disabled={disabled}\n      onClick={handleCopy}\n      aria-label={copied ? copiedLabel : label}\n      title={showLabel ? undefined : copied ? copiedLabel : label}\n      onPointerDown={() => !disabled && setPressed(true)}\n      onPointerUp={() => setPressed(false)}\n      onPointerLeave={() => setPressed(false)}\n      style={{\n        transform: pressed ? \"scale(0.95)\" : undefined,\n        transition: `transform 160ms ${EASE_SETTLE}, opacity 160ms ${EASE_OUT}, background-color 160ms ${EASE_OUT}`,\n      }}\n      className={`relative inline-flex items-center justify-center font-medium select-none cursor-pointer rounded-full transition-colors motion-reduce:transition-none disabled:cursor-not-allowed disabled:opacity-40 shrink-0 ${INTERACTION_FOCUS} ${v} ${\n        showLabel ? s.btn : s.iconOnly\n      } ${className}`}\n      {...props}\n    >\n      <span className=\"relative inline-flex items-center justify-center size-full shrink-0\">\n        {/* Checkmark icon with scale and opacity transition */}\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute inset-0 flex items-center justify-center transition-all duration-200 motion-reduce:transition-none\"\n          style={{\n            opacity: copied ? 1 : 0,\n            transform: copied ? \"scale(1)\" : \"scale(0.5)\",\n            filter: copied ? \"blur(0px)\" : \"blur(2px)\",\n            transitionTimingFunction: EASE_SETTLE,\n          }}\n        >\n          <AnimatedCheck drawn={copied} size={s.icon} />\n        </span>\n\n        {/* Copy icon with scale and opacity transition */}\n        <span\n          aria-hidden=\"true\"\n          className=\"flex items-center justify-center transition-all duration-200 motion-reduce:transition-none text-muted-foreground\"\n          style={{\n            opacity: copied ? 0 : 1,\n            transform: copied ? \"scale(0.5)\" : \"scale(1)\",\n            filter: copied ? \"blur(2px)\" : \"blur(0px)\",\n            transitionTimingFunction: EASE_SETTLE,\n          }}\n        >\n          <Copy size={s.icon} strokeWidth={1.75} />\n        </span>\n      </span>\n\n      {/* Optional Text Label */}\n      {showLabel && (\n        <span className=\"inline-block truncate font-medium ml-1.5\">\n          {copied ? copiedLabel : label}\n        </span>\n      )}\n\n      {/* Screen Reader live region announcement outside visual flow */}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {copied ? copiedLabel : \"\"}\n      </span>\n    </button>\n  );\n}\n\nexport default CopyButton;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/copy-button. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}