Copy Text to the Clipboard
Every dashboard, share modal, and code-block UI eventually needs a copy button. The modern path is `navigator.clipboard.writeText`, but it requires a secure context and a user gesture, so a robust helper falls back to a hidden `<textarea>` when the API is unavailable. This snippet covers the modern call, the legacy fallback, and a copy-with-toast helper that wraps both behind a single async function.
290 views
4
async function copyText(text) {
if (typeof navigator === 'undefined' || !navigator.clipboard) {
throw new Error('Clipboard API unavailable');
}
await navigator.clipboard.writeText(text);
return true;
}
copyText('hello clipboard')
.then(() => console.log('copied'))
.catch((e) => console.log('failed:', e.message));navigator.clipboard.writeText is the modern, async, permission-aware path. It only works in secure contexts (HTTPS or localhost) and inside a user gesture (click, keypress), so the call must run from an event handler, not a side effect on mount. The promise rejects with a NotAllowedError if either condition fails, which gives you a clear branch for falling back. Always wrap calls in try/catch: a thrown error here usually means the user denied the prompt or the page is in an iframe without clipboard-write permission.
function copyTextLegacy(text) {
try {
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 === true;
} catch (err) {
return false;
}
}
// In a real browser this returns true after copying.
console.log('copyTextLegacy returned:', copyTextLegacy('legacy path'));Older browsers (and some embedded webviews) lack the async clipboard API, but they support document.execCommand('copy') against a selected DOM node. The technique is to inject an off-screen <textarea>, fill it, select it, and trigger the copy command. Setting position: fixed keeps the page from scrolling, and opacity: 0 keeps it invisible. Wrap the whole thing in try/catch because execCommand may throw inside iframes without the clipboard-write allow attribute, or in environments without a real DOM. execCommand is deprecated but still the only fallback that works in older Safari and some legacy webviews.
async function copy(text, { onSuccess, onError } = {}) {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard && (typeof window === 'undefined' || window.isSecureContext)) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (!ok) throw new Error('execCommand failed');
}
if (onSuccess) onSuccess();
return true;
} catch (err) {
if (onError) onError(err);
return false;
}
}
copy('via combined helper', {
onSuccess: () => console.log('toast: copied'),
onError: (e) => console.log('toast: failed', e.message),
});Wrapping the modern path and the legacy fallback in one async function gives the UI layer a single API that always returns a boolean and never throws into the caller. The success and error callbacks are the right hook for showing a toast, animating a button, or firing analytics, so they belong in the helper rather than at every call site. Checking window.isSecureContext first avoids prompting the user in non-HTTPS dev environments where the API will deny anyway. Once installed, this is the only copy helper most apps need.
