Skip to content
    Style: Wireframe

    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,073 sites in The UI Index have a button (1,063 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/ui/r/copy-button.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/copy-button.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI 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;
    };