A copy button looks like a trivial UI element — press the button, copy some text, show a "Copied!" hint. In practice it is one of the easiest features in a web app to ship broken. It fails silently on insecure-context staging servers, misses focus on keyboard-only users, degrades ungracefully on Safari, and has a long tail of edge cases nobody hits until the app is live. This article walks through a copy button that handles all of them.
The modern one-liner
For most cases, the async Clipboard API's writeText method does the job:
button.addEventListener('click', async () => {
await navigator.clipboard.writeText('hello');
});
No prompt, no permission dance — inside a user gesture on an HTTPS page, this works in every modern browser. It is the right default.
Error handling that matters
writeText can reject. The three common reasons:
- Insecure context. On plain HTTP (not localhost),
navigator.clipboardis either undefined or throwsSecurityError. This hits staging environments that skip HTTPS. - Page not focused. If the user clicked somewhere outside the page between triggering the button and the call, Safari and Firefox reject with
NotAllowedError. In practice this rarely happens for button clicks but can happen for programmatic copies after a timer. - No user gesture. Calling outside a click, keypress, or touch handler fails.
Wrap the call and show a meaningful message on failure:
try {
await navigator.clipboard.writeText(value);
showCopied();
} catch (err) {
showCopyFailed(err);
}
The fallback: the textarea-and-execCommand trick
For insecure-context fallbacks, the legacy approach still works. Create a hidden <textarea>, set its value, select it, and call document.execCommand('copy'). execCommand is deprecated but implemented in every current browser precisely because removing it would break this pattern. Remove the textarea after:
function copyFallback(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
}
Keep the textarea position: fixed and opacity: 0. Do not set display: none — selection will not work on a display-none element.
Feedback the user can actually see
A "Copied!" tooltip that appears for 2 seconds is the minimum. The common mistakes:
- Using
alert()— it steals focus and blocks the tab. - Relying on tooltip libraries that require hover — keyboard users never see the message.
- Failing to tell the user when the copy fails, so they think it worked and paste stale content.
Swap the button label ("Copy" → "Copied!") for a beat, then restore. It is the clearest feedback and works for mouse, keyboard, and screen-reader users.
Accessibility
A screen reader does not announce a label change unless you tell it to. Wrap the button status in an aria-live="polite" region:
<button aria-describedby="copy-status">Copy</button>
<span id="copy-status" aria-live="polite" class="sr-only"></span>
On success, set the span's text to "Copied to clipboard". On failure, "Copy failed". The sr-only utility hides the span visually but keeps it in the accessibility tree.
Copying rich content, not just text
If you need to preserve formatting — say, letting a user copy a styled code block with syntax highlighting intact — use navigator.clipboard.write with a ClipboardItem:
const item = new ClipboardItem({
'text/plain': new Blob(['plain text'], { type: 'text/plain' }),
'text/html': new Blob(['<b>bold</b>'], { type: 'text/html' }),
});
await navigator.clipboard.write([item]);
Recipients that understand HTML get the formatted version; plain-text recipients get the fallback. Always include a text/plain key — not every destination can read HTML, and some strip fields entirely if plain text is absent.
Programmatic copy from non-click triggers
A common request: copy to clipboard when a user saves a form. This breaks the user-gesture requirement — the copy happens asynchronously after a network save, outside the click window. Three options:
- Copy synchronously when the user clicks the button, before the save. The save happens after.
- After the async save, show a button labelled "Copy" and let the user trigger the copy explicitly. This respects the gesture model and gives the user control.
- Use
navigator.clipboard.writewith a pre-resolved promise — the Clipboard API accepts a blob that resolves later, which works as long as the user gesture is still "alive" enough to cover the delay.
Keyboard shortcuts
If you want Ctrl+C to trigger the copy when the button is focused, listen for keydown on the button and call the same handler the click invokes. Do not call preventDefault unless you are sure no text is selected — otherwise you break the browser's default "copy selected text" behaviour for users who highlight and press Ctrl+C.
Testing it properly
Copy buttons are awkward to test because jsdom does not implement the Clipboard API. Two workable approaches:
- Stub
navigator.clipboard.writeTextin tests and assert the stub was called with the right value. - Run a Playwright or Cypress end-to-end test in a real browser and read back
navigator.clipboard.readText()to verify the content landed.
The second approach catches regressions the first misses — like accidentally serialising a React event object to the clipboard. Both are worth having.
A consolidated component
Pulling together the pieces, a working pattern looks like:
async function copy(text) {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
return copyFallback(text);
} catch {
return copyFallback(text);
}
}
Hook that into a button with visible and screen-reader feedback, and you have covered every path users actually take. To verify your output on the clipboard, paste into Clipboard Inspector and confirm the expected MIME types and values land. For the rich-content path with HTML alongside text, see the custom MIME type guide.