React useCopyToClipboard Hook
A copy-to-clipboard button needs to do three things: write to the clipboard, surface success or failure to the UI, and reset the success indicator after a short delay. This snippet covers a status-aware hook with a one-shot success flag, a fallback variant for older browsers, and a copy-with-format helper that writes both plain text and rich HTML. Drop it next to any 'Copy code' button.
804 views
21
function useCopyToClipboard(resetMs = 1500) {
const [status, setStatus] = useState('idle');
const copy = async (text) => {
try {
await navigator.clipboard.writeText(text);
setStatus('copied');
setTimeout(() => setStatus('idle'), resetMs);
return true;
} catch {
setStatus('error');
setTimeout(() => setStatus('idle'), resetMs);
return false;
}
};
return [status, copy];
}
function useState(v) { return [v, () => {}]; }
const [status, copy] = useCopyToClipboard();
copy('hello').then((ok) => console.log('copy ok?', ok, 'status:', status));The hook returns a status string (idle, copied, error) so UI can swap a tooltip or icon without storing a separate boolean. navigator.clipboard.writeText is async and rejects when the document does not have user-activation focus, hence the try/catch. The auto-reset via setTimeout is what turns the button back from a checkmark to the copy icon a moment later. The resolved boolean lets the caller chain side effects without subscribing to the status.
function copyTextLegacy(text) {
if (typeof document === 'undefined') return false;
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch { ok = false; }
document.body.removeChild(ta);
return ok;
}
function useCopyWithFallback() {
const [status, setStatus] = useState('idle');
const copy = async (text) => {
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
setStatus('copied');
return true;
}
} catch {}
const ok = copyTextLegacy(text);
setStatus(ok ? 'copied' : 'error');
return ok;
};
return [status, copy];
}
const [, copy2] = useCopyWithFallback();
copy2('legacy hello').then((ok) => console.log('legacy ok?', ok));Older Safari versions, embedded webviews, and some corporate browsers do not implement navigator.clipboard. The document.execCommand('copy') path with a hidden <textarea> still works there. The trick is creating an offscreen textarea, selecting its content, calling execCommand, then cleaning up. This API is officially deprecated but remains the only universally available fallback, so most production hooks layer it under the modern path with try / catch.
function useCopyRich() {
const copy = async (plain, html) => {
try {
if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
const item = new ClipboardItem({
'text/plain': new Blob([plain], { type: 'text/plain' }),
'text/html': new Blob([html], { type: 'text/html' }),
});
await navigator.clipboard.write([item]);
return true;
}
await navigator.clipboard.writeText(plain);
return true;
} catch { return false; }
};
return copy;
}
const copyRich = useCopyRich();
console.log('rich copy hook ready');Sometimes you want the clipboard to carry both a plain-text version and a styled HTML version (so pasting into a rich editor preserves formatting, while pasting into a plain field still works). ClipboardItem plus navigator.clipboard.write accepts a list of items, each a map of MIME types to Blobs. The fallback to writeText(plain) handles browsers that do not yet expose ClipboardItem. Use this for code snippets with syntax highlighting, citations from a documentation page, or markdown that converts to HTML.
