# Copy Button

> A copy-to-clipboard button whose clipboard icon spins into a drawn tick while the label swaps to Copied, without the button changing width.

Category: Buttons · Free · Updated 2026-10-23 · Live demo: https://motionkitui.com/components/copy-button

## When to use it

Code blocks, API keys, invite links, referral codes and install commands: anywhere people copy something and need to know it worked.

## How it works

Clicking writes the text with the Clipboard API, falling back to a hidden textarea and execCommand where that isn't available, such as some embedded browsers. On success, AnimatePresence swaps the clipboard icon for a tick, each one scaling and turning as it goes, and the tick draws in with pathLength. Both labels share one grid cell, so the button keeps its width as "Copy" becomes "Copied". A timer returns it to rest, and the change is announced in a status region.

## Installation

```bash
npx shadcn@latest add https://motionkitui.com/r/copy-button.json
```

Or copy `components/motionkit/copy-button.tsx` below. Requires react, motion and Tailwind CSS v4.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` |  | What gets copied. |
| `label` | `string` | `"Copy"` | Button text. Pass an empty string for an icon-only button. |
| `copiedLabel` | `string` | `"Copied"` | Shown and announced once copied. |
| `resetAfter` | `number` | `1800` | Milliseconds before it returns to rest. |
| `onCopy` | `() => void` |  | Called after a successful copy. |

## Accessibility

- **Keyboard: Pass.** A native <button>: Tab to reach, Enter or Space to copy, with a visible focus ring.
- **Screen readers: Pass.** Copying is announced in a status region; icon-only buttons get an accessible name that changes to Copied.
- **Reduced motion: Pass.** With reduced motion on, the icons crossfade without spinning and the tick appears already drawn.
- **Colour contrast: Pass.** The label measures 13.4:1 on its surface and the Copied state 6.8:1.

## Source

```tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type CopyButtonProps = {
  /** What gets copied. */
  text: string;
  /** Button text before copying. Leave empty for an icon-only button. */
  label?: string;
  /** Shown, and announced, once copied. */
  copiedLabel?: string;
  /** Milliseconds before it returns to its resting state. */
  resetAfter?: number;
  /** Called after a successful copy. */
  onCopy?: () => void;
  className?: string;
};

/** Copies with the Clipboard API, falling back to a hidden textarea where it isn't available. */
async function copy(text: string) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    const area = document.createElement("textarea");
    area.value = text;
    area.setAttribute("readonly", "");
    area.style.position = "fixed";
    area.style.opacity = "0";
    document.body.appendChild(area);
    area.select();
    const ok = document.execCommand("copy");
    area.remove();
    return ok;
  }
}

/** A copy-to-clipboard button: the clipboard icon turns into a drawn tick and the label swaps to "Copied". */
export function CopyButton({ text, label = "Copy", copiedLabel = "Copied", resetAfter = 1800, onCopy, className = "" }: CopyButtonProps) {
  const reduceMotion = useReducedMotion();
  const [copied, setCopied] = useState(false);
  const timer = useRef<number | undefined>(undefined);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  async function handleClick() {
    if (!(await copy(text))) return;
    setCopied(true);
    onCopy?.();
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setCopied(false), resetAfter);
  }

  const swap = reduceMotion
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
    : { initial: { opacity: 0, scale: 0.6, rotate: -20 }, animate: { opacity: 1, scale: 1, rotate: 0 }, exit: { opacity: 0, scale: 0.6, rotate: 20 } };

  return (
    <button
      type="button"
      onClick={handleClick}
      aria-label={label ? undefined : copied ? copiedLabel : "Copy to clipboard"}
      className={`inline-flex items-center gap-2 rounded-lg border border-white/10 bg-[#141B1B] px-3 py-2 text-sm text-[#DCE3E2] transition-colors hover:border-white/20 hover:text-[#F6F1E8] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#5FAEB4] ${className}`}
    >
      <span aria-hidden className="relative size-4">
        <AnimatePresence initial={false} mode="popLayout">
          {copied ? (
            <motion.svg key="tick" viewBox="0 0 16 16" className="absolute inset-0 size-4" fill="none" stroke="#5FAEB4" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...swap} transition={{ duration: 0.2 }}>
              <motion.path d="M3 8.5l3 3 7-7" initial={{ pathLength: reduceMotion ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.3, delay: 0.05 }} />
            </motion.svg>
          ) : (
            <motion.svg key="clip" viewBox="0 0 16 16" className="absolute inset-0 size-4" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinejoin="round" {...swap} transition={{ duration: 0.2 }}>
              <rect x="5" y="5" width="8.5" height="8.5" rx="1.75" />
              <path d="M10.5 5V3.75A1.25 1.25 0 0 0 9.25 2.5h-5.5A1.25 1.25 0 0 0 2.5 3.75v5.5a1.25 1.25 0 0 0 1.25 1.25H5" />
            </motion.svg>
          )}
        </AnimatePresence>
      </span>
      {label && (
        <span className="relative inline-grid">
          {/* both labels share one grid cell, so the button keeps its width as they swap */}
          <span className={`col-start-1 row-start-1 transition-opacity ${copied ? "opacity-0" : "opacity-100"}`}>{label}</span>
          <span aria-hidden className={`col-start-1 row-start-1 text-[#5FAEB4] transition-opacity ${copied ? "opacity-100" : "opacity-0"}`}>
            {copiedLabel}
          </span>
        </span>
      )}
      <span role="status" className="sr-only">
        {copied ? copiedLabel : ""}
      </span>
    </button>
  );
}
```
