Call navigator.clipboard.writeText(text) from a click handler. It returns a promise, so await it, then show a short Copied message and catch the error case. The API only works on HTTPS or localhost, and it needs a real user action such as a click, so do not call it on page load.
| Method | Copies | Status |
|---|---|---|
| navigator.clipboard.writeText() | Plain text | Baseline, widely available since March 2020 |
| navigator.clipboard.write([new ClipboardItem(...)]) | Text, HTML, PNG images | ClipboardItem is Baseline 2024 |
| copy event with event.clipboardData.setData() | Changes what a normal Ctrl+C copies | Widely supported |
| document.execCommand("copy") | The current selection | Deprecated, avoid in new code |
Updated
A copy button looks trivial, yet the snippets that rank often skip the parts that go wrong: the promise can reject, embedded frames can be blocked, and screen reader users never hear that anything happened. The demo below copies for real in your browser, and the code covers each of those cases.
Copy buttons for plain text and rich HTML, with a paste box to check the result.
The status element is visually hidden but read aloud. On failure the text is selected so the person can press Ctrl+C or Cmd+C.
<input id="share-url" value="https://example.com/invite/42" readonly>
<button type="button" id="copy">Copy link</button>
<span id="copy-status" role="status" class="visually-hidden"></span>
<script>
const input = document.getElementById("share-url");
const button = document.getElementById("copy");
const status = document.getElementById("copy-status");
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(input.value);
button.textContent = "Copied";
status.textContent = "Link copied to clipboard";
} catch {
input.select();
status.textContent = "Copy failed. Press Ctrl+C to copy.";
}
setTimeout(() => {
button.textContent = "Copy link";
status.textContent = "";
}, 2000);
});
</script>Returns the state and a copy function. The timer is cleared on unmount so it never updates a removed component.
import { useCallback, useEffect, useRef, useState } from "react";
export function useCopyToClipboard(resetMs = 2000) {
const [state, setState] = useState("idle"); // "idle" | "copied" | "error"
const timer = useRef();
useEffect(() => () => clearTimeout(timer.current), []);
const copy = useCallback(async (text) => {
clearTimeout(timer.current);
try {
await navigator.clipboard.writeText(text);
setState("copied");
} catch {
setState("error");
}
timer.current = setTimeout(() => setState("idle"), resetMs);
}, [resetMs]);
return [state, copy];
}
export function CopyButton({ text }) {
const [state, copy] = useCopyToClipboard();
return (
<>
<button type="button" onClick={() => copy(text)}>
{state === "copied" ? "Copied" : "Copy"}
</button>
<span role="status" className="sr-only">
{state === "copied" ? "Copied to clipboard" : state === "error" ? "Copy failed" : ""}
</span>
</>
);
}Each MIME type is a Blob. Paste targets pick the richest format they understand.
async function copyRich(html, text) {
const item = new ClipboardItem({
"text/html": new Blob([html], { type: "text/html" }),
"text/plain": new Blob([text], { type: "text/plain" }),
});
await navigator.clipboard.write([item]);
}
async function copyCanvasImage(canvas) {
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
}Use sparingly. Changing what people copy can annoy them, so keep it to quotes or code where a source helps.
document.querySelector("article").addEventListener("copy", (event) => {
const selected = document.getSelection().toString();
event.clipboardData.setData("text/plain", selected + "\n\nSource: " + location.href);
event.preventDefault();
});| What you see | Likely cause | Fix |
|---|---|---|
| navigator.clipboard is undefined | Page served over plain HTTP | Serve over HTTPS, or test on localhost |
| NotAllowedError | No user gesture, or the tab is not focused | Call it inside the click handler |
| NotAllowedError inside an iframe | Permissions Policy blocks clipboard-write | Add allow="clipboard-write" on the iframe |
| Image copy rejected | Unsupported MIME type | Convert to PNG and check ClipboardItem.supports() |
Writing to the clipboard is a privileged action, so browsers require a secure page, a focused document and, in Firefox and Safari, a fresh user gesture for each write. Chromium can also grant a persistent clipboard-write permission.
In practice, trigger the copy from a click or key press, do the write early in the handler, and treat failure as normal rather than exceptional.
Swap the label to Copied for about two seconds and then restore it. A tooltip that appears on hover alone is invisible on touch screens and to keyboard users.
Put the same message in a polite live region. role="status" announces it without interrupting whatever the screen reader was reading.
A ClipboardItem can hold a text/plain and a text/html version at once. Apps that understand HTML paste the formatted one, plain text fields take the other.
To change what a normal Ctrl+C copies, such as adding a source line, listen for the copy event, call event.preventDefault() and set your own data with event.clipboardData.setData().
The page is not in a secure context. The Clipboard API is only exposed on HTTPS pages and on localhost, so a site served over plain HTTP or from a local network address will not have it.
The browser refused the write, usually because there was no recent user action, the page was not focused, or a frame was blocked by Permissions Policy. Call it directly inside a click handler and avoid long awaits before it.
No. A ten line hook around navigator.clipboard.writeText covers it. Libraries mostly add an execCommand fallback for very old browsers, which current browsers do not need.
Yes, with navigator.clipboard.write and a ClipboardItem whose key is image/png and whose value is a PNG Blob. Use ClipboardItem.supports() to check a type first; PNG is the format browsers most reliably accept.
It is deprecated. It still works in many browsers under varying conditions, but new code should use the async Clipboard API, falling back to asking the person to copy manually.
Related: JavaScript event listeners, CSS button generator, Website accessibility checklist.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.