# Copy Button

The copy icon blurs into a check that draws itself, then swaps back.

**Category:** Display  
**Exports:** `CopyButton`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/copy-button.json
```
The file lands at `components/ui/copy-button.tsx`; import from `@/components/ui/copy-button`.

## API — Copy Button
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `string` | — |  |
| `label` | `string` | — | Optional visible text. |
| `timeout` | `number` | `1600` |  |
| `onCopied` | `(value: string) => void` | — |  |

## Example — Copy Button

```tsx
import { CopyButton } from "@/components/ui/copy-button";

export default function CopyButtonDemo({ timeout = 1600, label = "Copy ember hex" }: { timeout?: number; label?: string }) {
  const command = "npm i motion clsx tailwind-merge";
  return (
    <div className="flex flex-col items-center gap-4">
      <div className="flex items-center gap-2 rounded-xl border bg-background py-1.5 pl-4 pr-1.5">
        <code className="font-mono text-sm">{command}</code>
        <CopyButton value={command} timeout={timeout} />
      </div>
      <CopyButton value="#0285f7" label={label} timeout={timeout} className="border bg-background" />
    </div>
  );
}
```

## Source — components/ui/copy-button.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type CopyButtonProps = Omit<React.ComponentProps<"button">, "onClick" | "children"> & {
  /** The text written to the clipboard. */
  value: string;
  /** How long the success state stays visible, in ms. */
  timeout?: number;
  onCopied?: (value: string) => void;
  /** Optional visible label next to the icon. */
  label?: string;
};

const iconMotion = {
  initial: { opacity: 0, scale: 0.4, filter: "blur(4px)" },
  animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
  exit: { opacity: 0, scale: 0.4, filter: "blur(4px)" },
};

export function CopyButton({ value, timeout = 1600, onCopied, label, className, ...props }: CopyButtonProps) {
  const reduce = useReducedMotion();
  const [copied, setCopied] = React.useState(false);
  const timer = React.useRef<number | undefined>(undefined);

  React.useEffect(() => () => window.clearTimeout(timer.current), []);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(value);
    } catch {
      return;
    }
    setCopied(true);
    onCopied?.(value);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setCopied(false), timeout);
  };

  return (
    <button
      type="button"
      onClick={copy}
      aria-label={label ? undefined : copied ? "Copied" : "Copy to clipboard"}
      className={cn(
        "inline-flex h-8 min-w-8 cursor-pointer items-center justify-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground outline-none transition-colors",
        "hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
        className,
      )}
      {...props}
    >
      <span className="relative grid size-4 place-items-center">
        <AnimatePresence initial={false} mode="popLayout">
          {copied ? (
            <motion.svg
              key="check"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth={2.4}
              strokeLinecap="round"
              strokeLinejoin="round"
              className="size-4 text-success"
              {...(reduce ? {} : iconMotion)}
              transition={{ type: "spring", stiffness: 500, damping: 28 }}
              aria-hidden
            >
              <motion.path
                d="M5 12.5l4.5 4.5L19 7.5"
                initial={{ pathLength: reduce ? 1 : 0 }}
                animate={{ pathLength: 1 }}
                transition={{ duration: 0.3, ease: "easeOut", delay: 0.05 }}
              />
            </motion.svg>
          ) : (
            <motion.svg
              key="copy"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth={1.8}
              strokeLinecap="round"
              strokeLinejoin="round"
              className="size-4"
              {...(reduce ? {} : iconMotion)}
              transition={{ type: "spring", stiffness: 500, damping: 28 }}
              aria-hidden
            >
              <rect x="8" y="8" width="12" height="12" rx="2.5" />
              <path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8" />
            </motion.svg>
          )}
        </AnimatePresence>
      </span>
      {label && <span>{copied ? "Copied" : label}</span>}
      <span className="sr-only" aria-live="polite">
        {copied ? "Copied to clipboard" : ""}
      </span>
    </button>
  );
}
```