Copy to clipboard: browser rules and pitfalls
How the async Clipboard API works, why copies fail on plain http or after an await in Safari, which fallbacks still work, and how to confirm a copy.
5 min read
Copy buttons are everywhere in crypto interfaces: wallet addresses, transaction hashes, contract addresses, SVG code. They look trivial, and they fail in ways that are easy to miss during development. A button that works on localhost does nothing on your phone over the local network. A copy that works in Chrome quietly fails in Safari. A "Copied" message appears even though nothing reached the clipboard. This guide covers the rules browsers apply and how to write a copy function that holds up.
The async Clipboard API
The modern API is promise-based:
await navigator.clipboard.writeText(address)
It replaced document.execCommand('copy'), which worked by selecting text on the page. Plain text is all most copy buttons need. There's also navigator.clipboard.write(), which takes ClipboardItem objects for other formats; which formats work beyond plain text varies by browser.
Whether a write succeeds depends on three things: a secure context, user activation, and focus.
Rule 1: a secure context
The Clipboard API exists only in secure contexts: pages served over HTTPS, plus localhost and 127.0.0.1 during development. On a plain-http address, navigator.clipboard is undefined, so calling writeText on it throws.
This catches people testing on a phone over the local network. http://192.168.1.20:3000 isn't a secure context, so copying fails there even though it worked on localhost a minute earlier. Check rather than assume:
const hasClipboardApi = window.isSecureContext && navigator.clipboard !== undefined
Rule 2: user activation, and why an await can break it
Browsers don't let pages write to the clipboard whenever they like. Safari and Firefox expect the write to happen in response to a user action, such as a click, within the short window the HTML specification calls transient activation. Chrome is more lenient about writing text while the page has focus, which is why code tested only in Chrome can fail elsewhere.
The catch is what happens between the click and the write:
// Fragile: the write happens after a network round trip
button.addEventListener('click', async () => {
const res = await fetch('/icons/token/ETH.svg')
const svg = await res.text()
await navigator.clipboard.writeText(svg) // may be rejected in Safari
})
By the time the fetch resolves, Safari may no longer treat the write as part of the click, and the promise rejects with a NotAllowedError. How long that window lasts is up to each browser, so a fetch that's fast in testing can behave differently for someone on a slow connection. That makes the bug hard to reproduce.
There are two ways around it.
Load the content first, then write synchronously in the click. If you know what the user is likely to copy, such as the SVG of the currently selected icon, fetch it when the selection changes and keep it in memory. The click handler then calls writeText straight away with a string it already has.
const cache = new Map<string, string>()
export async function preload(url: string) {
if (cache.has(url)) return
const res = await fetch(url)
if (res.ok) cache.set(url, await res.text())
}
export function copyCached(url: string) {
const text = cache.get(url)
if (text === undefined) return Promise.reject(new Error('Not loaded yet'))
return copyText(text) // defined below
}
Pass a promise to ClipboardItem. The write() method accepts a ClipboardItem whose data is a promise. You call write() immediately inside the click, and the browser waits for the data:
button.addEventListener('click', () => {
const data = fetch(url)
.then((res) => res.text())
.then((text) => new Blob([text], { type: 'text/plain' }))
navigator.clipboard
.write([new ClipboardItem({ 'text/plain': data })])
.then(showCopied, showCopyError)
})
Safari supports this pattern, which exists for exactly this situation. Support in other browsers has arrived over time, so feature-detect ClipboardItem and test in every browser you target. Preloading is simpler when you can predict what will be copied.
Rule 3: focus and frames
Chrome rejects clipboard writes when the document doesn't have focus. You'll see this when you call writeText from the DevTools console: DevTools has focus, so the write fails with a "Document is not focused" error.
Inside an iframe, such as a widget embedded in a dApp or a sandboxed preview, the embedding page has to allow clipboard writes through a permissions policy, for example allow="clipboard-write" on the iframe element. Without it, writes from a cross-origin frame fail.
A fallback for insecure contexts
document.execCommand('copy') is deprecated, but browsers still support it, and it works where the async API isn't available, such as on plain http. It also has to run inside a user action. The usual technique selects text in a temporary textarea:
function legacyCopy(text: string): boolean {
const previous = document.activeElement as HTMLElement | null
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.append(textarea)
textarea.select()
textarea.setSelectionRange(0, text.length)
let ok = false
try {
ok = document.execCommand('copy')
} catch {
ok = false
}
textarea.remove()
previous?.focus()
return ok
}
export async function copyText(text: string): Promise<void> {
if (window.isSecureContext && navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text)
}
if (legacyCopy(text) === false) throw new Error('Copy failed')
}
readonly keeps mobile keyboards from opening, and position: fixed stops the page from jumping to the textarea. Restoring focus afterwards returns keyboard users to the button they pressed.
Don't ask the Permissions API first. navigator.permissions.query({ name: 'clipboard-write' }) isn't supported in every browser and can throw. Attempt the write and handle the failure.
Confirm the copy, but only on success
Copying has no visible effect, so people need feedback. Change the button label briefly, from "Copy" to "Copied", and switch it back after about two seconds. Do it only after the promise resolves: a confirmation for a copy that failed is worse than no confirmation.
import { useState } from 'react'
export function CopyButton({ text, label = 'Copy address' }: { text: string; label?: string }) {
const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle')
async function onClick() {
try {
await copyText(text)
setState('copied')
} catch {
setState('failed')
}
setTimeout(() => setState('idle'), 2000)
}
return (
<>
<button type="button" onClick={onClick}>
{state === 'copied' ? 'Copied' : label}
</button>
<span role="status" className="visually-hidden">
{state === 'copied' && 'Copied to clipboard'}
{state === 'failed' && 'Copy failed. Select the text and copy it manually.'}
</span>
</>
)
}
The status element announces the result to screen reader users, because a changed button label alone isn't announced reliably by every screen reader. When a copy fails, show the text in a selectable field so people can copy it themselves. For addresses in particular, see displaying wallet addresses, which covers truncation and showing the full value.
Copying SVG code
For icons, copying the SVG markup as plain text is what most people want: design tools such as Figma turn pasted SVG code into vector layers, and code editors take it as is. Writing the image/svg+xml type to the clipboard isn't consistently supported across browsers, so plain text is the dependable choice. When people want a file rather than markup, a download is the better tool; triggering file downloads covers that side.