Skip to content
Caseroom

Components · Actions

Copy button

Copies a value, an element's text or a whole page (its markdown), then says Copied for a moment.

51% of the 2,062 sites in The UI Index have a button (1,054 sites).

npx shadcn add @caseroom/copy-button

Both previews render the same markup; the HTML tab below is that markup without a framework.

Install

React
npx shadcn@latest add https://www.themes.dev/caseroom/r/copy-button.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/copy-button.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.

copy-button.client.ts
// Copy button behaviour for Astro, HTML and React: [data-copy] copies data-copy-value, the text of the element in
// data-copy-from, or the file at data-copy-url. The button shows the result for a moment (data-state copied or failed,
// the label changes) and one polite live region, made on first use, repeats it for screen readers.
// Security: any <button data-copy> on the page copies on click, so a page that shows untrusted HTML must strip
// data-copy* attributes from it (as it strips on* handlers), or a planted button could put a command of its choosing
// on the reader's clipboard. Only <button> elements count, and data-copy-url only fetches from this page's origin.
declare global {
  interface Window {
    __caseroomCopy?: boolean;
  }
}

const say = (text: string) => {
  let live = document.getElementById('caseroom-copy-live');
  if (!live) {
    live = Object.assign(document.createElement('div'), { id: 'caseroom-copy-live', className: 'sr-only' });
    live.setAttribute('role', 'status');
    document.body.append(live);
  }
  live.textContent = '';
  requestAnimationFrame(() => (live!.textContent = text));
};

const fetchText = async (url: string) => {
  const target = new URL(url, location.href);
  if (target.origin !== location.origin) throw new Error('cross-origin copy url');
  const res = await fetch(target, { credentials: 'same-origin' });
  if (!res.ok) throw new Error(String(res.status));
  return res.text();
};

const copy = async (b: HTMLButtonElement) => {
  const { copyValue, copyFrom, copyUrl } = b.dataset;
  if (copyUrl) {
    // Safari keeps the click's permission only for a write that starts now: hand it the fetch as a promise.
    if (typeof ClipboardItem !== 'undefined' && 'write' in navigator.clipboard) {
      const blob = fetchText(copyUrl).then((t) => new Blob([t], { type: 'text/plain' }));
      try {
        await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]);
        return;
      } catch {
        /* fall back to writeText below */
      }
    }
    await navigator.clipboard.writeText(await fetchText(copyUrl));
    return;
  }
  const text = copyFrom ? (document.querySelector(copyFrom)?.textContent ?? '').trim() : (copyValue ?? '');
  await navigator.clipboard.writeText(text);
};

if (typeof window !== 'undefined' && !window.__caseroomCopy) {
  window.__caseroomCopy = true;
  const timers = new WeakMap<HTMLButtonElement, number>();
  document.addEventListener('click', async (e) => {
    const b = (e.target as Element).closest<HTMLButtonElement>('button[data-copy]');
    if (!b || e.defaultPrevented || b.matches(':disabled')) return;
    const label = b.querySelector<HTMLElement>('[data-copy-label]');
    const idle = b.dataset.copyIdle ?? (b.dataset.copyIdle = label?.textContent ?? '');
    let ok = true;
    try {
      await copy(b);
    } catch {
      ok = false;
    }
    b.dataset.state = ok ? 'copied' : 'failed';
    if (label) label.textContent = ok ? (b.dataset.copiedLabel ?? 'Copied') : "Couldn't copy";
    say(ok ? 'Copied to the clipboard' : "Couldn't copy to the clipboard");
    clearTimeout(timers.get(b));
    timers.set(b, window.setTimeout(() => {
      delete b.dataset.state;
      if (label) label.textContent = idle;
    }, 1800));
  });
}

export {};
copy-button.tsx
import { cn } from '@/lib/utils';
import { buttonVariants, type ButtonVariants } from '../button/button.variants';
import { CHECK_PATH, COPY_PATH, CROSS_PATH, copyButton, type CopySource } from './copy-button.variants';
import './copy-button.client';

export interface CopyButtonProps extends CopySource {
  /** The label, e.g. "Copy page" or "Copy link". With size icon it is read out but not shown. */
  label?: string;
  copiedLabel?: string;
  variant?: ButtonVariants['variant'];
  size?: ButtonVariants['size'];
  className?: string;
}

/** Copies a value, an element's text or a fetched file, and says "Copied" for a moment. */
export function CopyButton({ value, from, url, label = 'Copy', copiedLabel = 'Copied', variant = 'outline', size = 'sm', className }: CopyButtonProps) {
  const svg = { viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true } as const;
  return (
    <button type="button" data-copy="" data-copy-value={value} data-copy-from={from} data-copy-url={url} data-copied-label={copiedLabel} className={cn(buttonVariants({ variant, size }), 'group', className)}>
      <svg {...svg} className={copyButton.icon}><path d={COPY_PATH} /></svg>
      <svg {...svg} className={copyButton.check}><path d={CHECK_PATH} /></svg>
      <svg {...svg} className={copyButton.cross}><path d={CROSS_PATH} /></svg>
      <span data-copy-label="" className={size === 'icon' ? 'sr-only' : undefined}>{label}</span>
    </button>
  );
}
copy-button.variants.ts
/** A copy button: copies a value, the text of an element, or a file fetched from a URL ("Copy page" copies a page's
 *  markdown). The icon turns into a check and the label into "Copied" for a moment; a polite live region repeats it. */
export const copyButton = {
  // The button's own variant and size come from button.variants; these are the parts inside it.
  icon: 'group-data-[state=copied]:hidden group-data-[state=failed]:hidden',
  check: 'hidden group-data-[state=copied]:block',
  cross: 'hidden group-data-[state=failed]:block',
};

export const COPY_PATH = 'M5.5 5.5V3.75c0-.69.56-1.25 1.25-1.25h5.5c.69 0 1.25.56 1.25 1.25v5.5c0 .69-.56 1.25-1.25 1.25H10.5M3.75 5.5h5.5c.69 0 1.25.56 1.25 1.25v5.5c0 .69-.56 1.25-1.25 1.25h-5.5c-.69 0-1.25-.56-1.25-1.25v-5.5c0-.69.56-1.25 1.25-1.25z';
export const CHECK_PATH = 'M3.5 8.5l3 3 6-7';
export const CROSS_PATH = 'M4.5 4.5l7 7M11.5 4.5l-7 7';

export type CopySource = {
  /** The text to copy. */
  value?: string;
  /** Or a CSS selector: the text of that element is copied. */
  from?: string;
  /** Or a URL on the same origin: the file is fetched and its text copied (e.g. a page's .md alternate). */
  url?: string;
};