How to add a “Copy” button — and why it stays silent on http

A "Copy" button under an AI chat answer or a code block is one line of JavaScript. You write it, test it on your machine — it works. You put it on a test server and open it by IP address — the button does nothing. And the console says:
TypeError: Cannot read properties of undefined (reading 'writeText')
Your code isn't broken. navigator.clipboard simply exists only in a secure context. On plain http it isn't there at all. Let's build a button that works wherever it can — and says so honestly when it can't.
What you'll need
- A page served over HTTPS or on localhost. MDN treats
http://localhostas secure — that's why it worked locally. - The text you want to copy.
- Fifteen lines of JavaScript. No library needed:
writeTexthas been in Chrome since version 66, Firefox since 63 and Safari since 13.1.
Step 1. Markup
<pre id="answer">npm install zod</pre>
<button type="button" class="copy" data-target="answer">Copy</button>
<span class="copy-status" role="status"></span>
Three details. type="button" — so the button doesn't submit a form it sits inside. data-target — where to take the text from. And the empty span with role="status" is for step 4. It has to be in the HTML from the start, not added later.
Step 2. Copying
document.querySelectorAll('.copy').forEach((button) => {
button.addEventListener('click', async () => {
const text = document.getElementById(button.dataset.target).textContent;
const status = button.nextElementSibling;
try {
await navigator.clipboard.writeText(text);
status.textContent = 'Copied';
} catch (error) {
status.textContent = "Couldn't copy — select the text and press Ctrl+C";
}
setTimeout(() => { status.textContent = ''; }, 2000);
});
});
writeText returns a promise: copying is asynchronous, hence await. If the browser refuses to write, the promise rejects with a NotAllowedError. And if navigator.clipboard doesn't exist at all, try catches that error too. Either way, the person gets a clear hint instead of silence.
textContent takes the text exactly as it is in the code, with every space and line break. For code blocks, that's exactly what you want.
Step 3. Copy right inside the click handler
Browsers don't open the clipboard whenever they feel like it — only right after the person does something. This is called transient activation. In Firefox and Safari, writing won't go through without it. Chromium allows it either with activation or with the clipboard-write permission.
That leads to two mistakes:
- Copying on page load or on a timer. The person didn't click anything — the clipboard is closed.
- Waiting a long time for a server response first, then copying. The activation can expire, especially in Safari.
The fix is simple: prepare the text in advance. In the click handler — only writeText, with no slow requests before it.
Step 4. A confirmation people can see and hear
A "Copied" label for two seconds works for the eyes. But a blind person won't see it. That's why the status lives in an element with role="status". When its text changes, the screen reader calmly, without interrupting, says "Copied".
Don't make the confirmation just an icon swap or a color change. A screen reader won't announce a checkmark replacing the copy icon, and someone with low vision may not notice it.
Step 5. If the button lives inside an iframe
A chat widget, an embedded code block, a demo on someone else's site — all of these are iframes. In Chromium browsers the frame has to be explicitly allowed to write:
<iframe src="https://widget.example.com" allow="clipboard-write"></iframe>
Without it, writeText inside the frame fails with NotAllowedError, even on click.
What you get
A button under any block of text or code. Click — text on the clipboard — a "Copied" label for two seconds that the screen reader reads aloud. On http outside localhost, the person sees a "select and press Ctrl+C" hint instead of a dead button.
For an AI chat, add one rule. If the answer arrives as a stream, keep the button disabled until the stream finishes. Otherwise someone clicks halfway through and copies half an answer.
Why it won't copy: three common causes
- The page is on
httpand not on localhost.navigator.clipboardisundefined. Check: typewindow.isSecureContextin the console — it should betrue. - Copying isn't triggered by a user action. Page load,
setTimeout, a long request before the write. - The button is in a frame without
allow="clipboard-write". Check: the same button outside the frame works.
As for the old document.execCommand('copy') approach, MDN marks it as deprecated. For new code — Clipboard API only.
Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.
Can I copy an image instead of text?
Yes, with navigator.clipboard.write() and a ClipboardItem object. PNG is the safest format to copy: Chrome, Firefox and Safari all support it.
Can a site read my clipboard without asking?
No. Reading requires a user action. Chrome also asks for permission, while Firefox and Safari show a small menu with a "Paste" button.
Why do line numbers get copied along with the code?
Because the numbers are drawn as separate elements inside the block, and textContent grabs them along with the code. Keep the clean text separately — say, in a data-code attribute on the button — and copy that. Or draw the numbers with CSS counters: that text never lands on the clipboard.
How do I do the same thing in React?
Same logic; only where the status lives changes:
function CopyButton({ text }) {
const [status, setStatus] = useState('');
async function copy() {
try {
await navigator.clipboard.writeText(text);
setStatus('Copied');
} catch {
setStatus("Couldn't copy — select the text and press Ctrl+C");
}
setTimeout(() => setStatus(''), 2000);
}
return (
<>
<button type="button" onClick={copy}>Copy</button>
<span role="status">{status}</span>
</>
);
}
The text comes in as a prop instead of being read from the DOM — so extras like line numbers never reach the clipboard.





