Copy to Clipboard Button in JavaScript: Modern API, Fallback and Feedback
Add a copy button with the Clipboard API, a fallback for older browsers and clear feedback for every visitor. Code, live demo and fixes for common errors.

Nobody enjoys selecting a long command with a mouse and hoping they got every character. A copy button is a small piece of interface that saves a lot of those small annoyances. The part that surprises people is how many ways it can fail quietly.
This tutorial builds a button that copies text, confirms that it worked, and tells the truth when it did not.
What you will build
A dark code box with a Copy button. Click it, and the label changes to "Copied" for a moment. Paste into the field underneath to check what actually landed on the clipboard.
The frame is small, so open the demo in its own tab to try it at full size.
The markup
<!-- In the <head>: <link rel="stylesheet" href="/style.css"> -->
<main class="page">
<h1>Install</h1>
<p>Run this in your project folder.</p>
<div class="snippet">
<code id="install-command">npm install fieldnotes-ui</code>
<button class="copy" type="button" data-copy-target="#install-command">Copy</button>
</div>
<p class="status" id="status" role="status"></p>
<p class="tip">Paste into the box below to check what was copied.</p>
<input class="paste" type="text" aria-label="Paste area" placeholder="Paste here">
</main>
<script src="/script.js" defer></script>
The button does not contain the text to copy. It points at the element that does, through data-copy-target. That means one script serves every copy button on the page, and each button costs you a single attribute.
The p with role="status" is a live region. When the label on the button changes, screen reader users would not otherwise hear anything, so the same message is written there.
The JavaScript
const status = document.querySelector("#status");
async function copyText(text) {
// The modern API needs HTTPS (or localhost) and a user gesture.
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
// Fallback for older browsers and plain-HTTP pages.
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.append(area);
area.select();
const ok = document.execCommand("copy");
area.remove();
if (!ok) throw new Error("Copy failed");
}
document.querySelectorAll("[data-copy-target]").forEach((button) => {
const label = button.textContent;
let timer;
button.addEventListener("click", async () => {
const source = document.querySelector(button.dataset.copyTarget);
try {
await copyText(source.textContent.trim());
button.textContent = "Copied";
status.textContent = "Copied to clipboard.";
} catch {
button.textContent = "Press Ctrl+C";
status.textContent = "Copy was blocked. Select the text and press Ctrl+C.";
}
clearTimeout(timer);
timer = setTimeout(() => {
button.textContent = label;
}, 1800);
});
});
copyText tries the modern route first: navigator.clipboard.writeText. It is asynchronous and returns a promise, which is why the click handler is an async function using await.
If that API is not available, the function falls back to the older method. It creates an off-screen textarea, selects its content, runs document.execCommand("copy"), and removes it. That path covers older browsers and pages served over plain HTTP.
Whichever path runs, a failure becomes an exception, and the handler shows an honest message instead of pretending the copy worked.
Common problems and how to fix them
navigator.clipboard is undefined
The Clipboard API only exists in secure contexts: HTTPS pages and localhost. Open your file from a plain http:// address on another machine, and the property is missing. The window.isSecureContext check sends those visitors to the fallback.
A NotAllowedError is thrown
Browsers only allow clipboard writes in response to a user action, such as a click. Calling writeText when the page loads, or from a timer, is rejected. Keep the call inside the click handler.
It works on the page but not inside an embedded frame
A page loaded in an iframe from another site needs permission to write to the clipboard. The embedding page has to add allow="clipboard-write" to the iframe. Pages on your own origin are fine without it.
The pasted text has extra spaces or line breaks
The text that you copy is exactly what is in the element, including the whitespace around it in your HTML. Calling .trim() on the string, as the demo does, removes the surrounding space. For multi-line code blocks, read textContent rather than innerText to avoid layout-dependent line breaks.
Visitors do not notice anything happened
Feedback is half the feature. The label change gives sighted users a signal, and the live region covers everyone else. Restore the original label after a short delay so the button can be used again, and clear the earlier timer first so rapid clicks do not leave it stuck on "Copied".
One thing not to do
Do not write to the visitor's clipboard without being asked. Replacing what someone copied, for example by listening to the copy event and adding text, is a quick way to lose their trust. Copy only when they press your button.