# Copy Button

An icon button that copies a value and swaps to a check mark, announcing it to screen readers.

Docs: https://corsairui.vercel.app/docs/copy-button

## Install

```bash
pnpm dlx shadcn@latest add @corsair-ui/copy-button
```

Also adds: `utils`, `button`.
npm: `@radix-ui/react-slot`, `class-variance-authority`, `clsx`, `lucide-react`, `tailwind-merge`.

## API

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` |  | The text that lands on the clipboard. |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Square icon sizes. |
| `timeout` | `number` | `1600` | Ms the check stays. |
| `onCopied` | `(value) => void` |  | Only after the clipboard accepted it. |
| `label / copiedLabel` | `string` | `"Copy to clipboard" / "Copied"` | Name, and what is announced. |

## Styling

Restyle it in the project that installs it: `className` on each part, the theme variables, and these attributes with arbitrary variants (`[&_[data-slot=…]]:…`, `data-[state=…]:…`).

- data-state is idle or copied, and each icon has its own slot: [&_[data-slot=copy-button-check-icon]]:text-success colours only the check.

data-slot: `copy-button`, `copy-button-check-icon`, `copy-button-copy-icon`

State attributes:

- `data-state`: `copied`, `idle`

## Accessibility

- "Copied" is announced through a live region, and never claimed when the browser refused.
- It stays enabled while it shows the check, so keyboard focus is never dropped.

## Examples

On the docs page: Sizes and outline, In an input group. The usage examples in the source comments below are the reference.

## Source

What `shadcn add` writes into the project:

### copy-button.tsx

```tsx
"use client";

import { CheckIcon, CopyIcon } from "lucide-react";
import { useEffect, useRef, useState, type ComponentProps, type MouseEvent } from "react";

import { cn } from "@/registry/default/lib/utils";
import { Button } from "@/registry/default/ui/button";

const SIZES = { sm: "icon-sm", default: "icon", lg: "icon-lg" } as const;

interface CopyButtonProps extends Omit<
  ComponentProps<typeof Button>,
  "value" | "size" | "children" | "asChild" | "loading"
> {
  /** The text that lands on the clipboard. */
  value: string;
  size?: keyof typeof SIZES;
  /** How long the check mark stays before the button resets, in ms. */
  timeout?: number;
  /** Called after the text was copied, not when the browser refused. */
  onCopied?: (value: string) => void;
  /** Accessible name of the button. */
  label?: string;
  /** Announced to screen readers once the text is on the clipboard. */
  copiedLabel?: string;
}

/**
 * An icon button that copies `value` and swaps its icon for a check mark.
 * "Copied" is announced through a live region and only claimed when the
 * clipboard accepted the text. The button stays enabled while it shows the
 * check, so keyboard focus is never dropped.
 *
 * `data-state` is `idle` or `copied`, and the icons are
 * `data-slot="copy-button-copy-icon"` and `"copy-button-check-icon"`, e.g.
 * `className="[&_[data-slot=copy-button-check-icon]]:text-success"`.
 *
 * @example
 * <CopyButton value="pnpm dlx shadcn@latest add @corsair-ui/copy-button" />
 */
function CopyButton({
  value,
  size = "default",
  timeout = 1600,
  onCopied,
  label = "Copy to clipboard",
  copiedLabel = "Copied",
  variant = "ghost",
  className,
  onClick,
  ...props
}: CopyButtonProps) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);

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

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(value);
    } catch {
      // Refused, or not a secure context: nothing was copied, so claim nothing.
      return;
    }
    setCopied(true);
    onCopied?.(value);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), timeout);
  };

  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
    onClick?.(event);
    if (!event.defaultPrevented) void copy();
  };

  const icon =
    "col-start-1 row-start-1 motion-safe:transition-[opacity,transform,filter] motion-safe:duration-200 motion-safe:ease-out";

  return (
    <>
      <Button
        type="button"
        data-slot="copy-button"
        data-state={copied ? "copied" : "idle"}
        variant={variant}
        size={SIZES[size]}
        aria-label={label}
        className={cn("grid place-items-center active:scale-95", className)}
        onClick={handleClick}
        {...props}
      >
        <CheckIcon
          aria-hidden="true"
          data-slot="copy-button-check-icon"
          className={cn(icon, copied ? "scale-100 opacity-100" : "scale-50 opacity-0 blur-[2px]")}
        />
        <CopyIcon
          aria-hidden="true"
          data-slot="copy-button-copy-icon"
          className={cn(icon, copied ? "scale-50 opacity-0 blur-[2px]" : "scale-100 opacity-100")}
        />
      </Button>
      <span role="status" aria-live="polite" className="sr-only">
        {copied ? copiedLabel : ""}
      </span>
    </>
  );
}

export { CopyButton, type CopyButtonProps };
```
