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-buttonnpx shadcn add @caseroom/copy-buttonBoth 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 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 {};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>
);
}/** 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;
};---
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';
interface Props 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'];
class?: string;
}
const { value, from, url, label = 'Copy', copiedLabel = 'Copied', variant = 'outline', size = 'sm', class: className } = Astro.props;
---
<button type="button" data-copy data-copy-value={value} data-copy-from={from} data-copy-url={url} data-copied-label={copiedLabel} class={cn(buttonVariants({ variant, size }), 'group', className)}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class={copyButton.icon}><path d={COPY_PATH} /></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class={copyButton.check}><path d={CHECK_PATH} /></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class={copyButton.cross}><path d={CROSS_PATH} /></svg>
<span data-copy-label class={size === 'icon' ? 'sr-only' : undefined}>{label}</span>
</button>
<script>
import './copy-button.client';
</script>// 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 {};/** 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;
};<div class="grid w-full max-w-md grid-cols-[minmax(0,1fr)] justify-items-start gap-5">
<div class="flex flex-wrap gap-2">
<button type="button" data-copy data-copy-url="/caseroom/components/copy-button.md" data-copied-label="Page copied" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border border-input bg-background hover:bg-accent hover:text-accent-foreground active:bg-pressed h-8 px-3 text-[13px] group">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="group-data-[state=copied]:hidden group-data-[state=failed]:hidden"><path d="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"></path></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="hidden group-data-[state=copied]:block"><path d="M3.5 8.5l3 3 6-7"></path></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="hidden group-data-[state=failed]:block"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7"></path></svg>
<span data-copy-label>Copy page</span>
</button>
<button type="button" data-copy data-copy-value="https://www.themes.dev/caseroom/components/copy-button/" data-copied-label="Link copied" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground active:bg-pressed h-8 px-3 text-[13px] group">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="group-data-[state=copied]:hidden group-data-[state=failed]:hidden"><path d="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"></path></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="hidden group-data-[state=copied]:block"><path d="M3.5 8.5l3 3 6-7"></path></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="hidden group-data-[state=failed]:block"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7"></path></svg>
<span data-copy-label>Copy link</span>
</button>
</div>
<div class="flex w-full items-center gap-1 rounded-md border bg-muted py-1 pr-1 pl-3">
<code id="ex-copy-cmd" class="min-w-0 flex-1 truncate font-mono text-[13px] text-muted-foreground">npx shadcn add @caseroom/copy-button</code>
<button type="button" data-copy data-copy-from="#ex-copy-cmd" data-copied-label="Copied" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground active:bg-pressed group size-8">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="group-data-[state=copied]:hidden group-data-[state=failed]:hidden"><path d="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"></path></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="hidden group-data-[state=copied]:block"><path d="M3.5 8.5l3 3 6-7"></path></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="hidden group-data-[state=failed]:block"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7"></path></svg>
<span data-copy-label class="sr-only">Copy the command</span>
</button>
</div>
</div><script type="module" src="/caseroom/r/html/copy-button.js"></script>