Copying Formatted HTML to Clipboard Without execCommand
This guide is part of Mastering the Clipboard API for Rich Text, which is itself part of Native Device Integration Patterns.
document.execCommand('copy') is deprecated. It cannot write structured multi-MIME data, operates synchronously on the main thread, and produces unpredictable results in PWAs, mobile webviews, and sandboxed iframes. This page walks through the exact steps to replace it with navigator.clipboard.write() using a ClipboardItem dual-MIME payload, and documents every failure mode you will encounter in production.
Feature Detection Gate
Check availability before touching any clipboard API. Three conditions must all pass: the page must be in a secure context, navigator.clipboard must exist, and ClipboardItem must be defined. Each missing piece routes to a different fallback.
/**
* Returns the highest-capability clipboard write tier available.
* @returns {'modern' | 'text-only' | 'legacy' | 'unavailable'}
*/
export function detectClipboardTier() {
if (!window.isSecureContext) return 'unavailable';
if (typeof navigator.clipboard === 'undefined') return 'legacy';
if (typeof ClipboardItem === 'undefined') return 'text-only';
return 'modern';
}
Call this during component mount. Use the result to decide which copy controls to show — there is no point rendering an “Export as Rich Text” button when the runtime tier is 'legacy' or 'unavailable'.
Solution Walkthrough
Step 1 — Sanitise the HTML and derive plain text
Never pass raw user-generated HTML directly into a Blob. Strip <script> tags, inline event handlers, and javascript: href values before serialisation to prevent XSS payloads from reaching any application that interprets pasted HTML.
/**
* Sanitises HTML markup and returns a {safeHtml, plainText} pair.
* @param {string} rawHtml
* @returns {{ safeHtml: string, plainText: string }}
*/
export function sanitiseForClipboard(rawHtml) {
const doc = new DOMParser().parseFromString(rawHtml, 'text/html');
// Remove dangerous elements entirely
doc.querySelectorAll('script, link[rel="stylesheet"], iframe').forEach(el => el.remove());
// Strip inline event handlers (onclick, onfoo, etc.)
doc.querySelectorAll('*').forEach(el => {
[...el.attributes].forEach(attr => {
if (attr.name.startsWith('on')) el.removeAttribute(attr.name);
});
// Remove javascript: hrefs
if (el.href && /^javascript:/i.test(el.getAttribute('href'))) {
el.removeAttribute('href');
}
});
return {
safeHtml: doc.body.innerHTML,
plainText: (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim(),
};
}
The plainText value should read as genuine prose — not a dump of tag names. Preserve paragraph breaks as newlines and tab-separate table columns before passing the result to textContent if your content includes structured data.
Step 2 — Build the dual-MIME ClipboardItem
A ClipboardItem maps MIME-type strings to Blob instances. Always include both text/html and text/plain. WebKit drops the text/html payload silently when text/plain is absent, and applications that accept only plain text have no usable paste value without it.
/**
* Constructs a ClipboardItem with text/html and text/plain blobs.
* Assumes safeHtml has already been sanitised.
* @param {string} safeHtml
* @param {string} plainText
* @returns {ClipboardItem}
*/
export function buildClipboardItem(safeHtml, plainText) {
return new ClipboardItem({
'text/html': new Blob([safeHtml], { type: 'text/html' }),
'text/plain': new Blob([plainText], { type: 'text/plain' }),
});
}
Step 3 — Call navigator.clipboard.write() inside the gesture handler
The browser’s user-activation mechanism is consumed at call time. await is permitted because async functions do not break the activation chain — but wrapping the call in setTimeout, requestAnimationFrame, or a fetch callback does break it. Keep the write in the synchronous body of the event listener.
The async payload formatting patterns that apply to the Web Share API apply identically here: construct the payload synchronously, then await the write.
/**
* Copies rich HTML to the clipboard with a three-tier fallback chain.
* MUST be called from a direct user event handler (click, keydown).
* @param {string} rawHtml Unsanitised HTML from the application
* @returns {Promise<{ success: boolean, method: string, error?: string }>}
*/
export async function copyFormattedHtml(rawHtml) {
const { safeHtml, plainText } = sanitiseForClipboard(rawHtml);
const tier = detectClipboardTier();
// Tier 1: full ClipboardItem support
if (tier === 'modern') {
try {
await navigator.clipboard.write([buildClipboardItem(safeHtml, plainText)]);
return { success: true, method: 'clipboard-item' };
} catch (err) {
const name = err instanceof DOMException ? err.name : 'Unknown';
if (name !== 'NotAllowedError' && name !== 'SecurityError') {
return { success: false, method: 'clipboard-item', error: name };
}
// NotAllowedError / SecurityError — fall through to text-only
}
}
// Tier 2: plain text only (Firefox pre-126, or after permission denied above)
if (tier === 'modern' || tier === 'text-only') {
try {
await navigator.clipboard.writeText(plainText);
return { success: true, method: 'writeText' };
} catch {
// Fall through to legacy
}
}
// Tier 3: legacy execCommand textarea injection
return legacyExecCommandFallback(plainText);
}
/**
* Last-resort clipboard write for non-secure or very old environments.
* @param {string} text
* @returns {{ success: boolean, method: string }}
*/
export function legacyExecCommandFallback(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0;pointer-events:none;';
document.body.appendChild(ta);
let success = false;
try {
ta.select();
ta.setSelectionRange(0, ta.value.length); // required for iOS Safari
success = document.execCommand('copy');
} catch {
// execCommand not available
} finally {
document.body.removeChild(ta);
}
return { success, method: 'legacy' };
}
Step 4 — Wire up the copy button and surface feedback
Return a result object from every path and use it to drive UI feedback. Silently swallowing failures means the user discovers the problem at paste time.
/**
* Attaches the rich-text copy flow to a button element.
* @param {HTMLButtonElement} button
* @param {() => string} getHtml Callback that returns the raw HTML to copy
*/
export function attachCopyButton(button, getHtml) {
button.addEventListener('click', async () => {
const result = await copyFormattedHtml(getHtml());
if (result.success) {
button.textContent = 'Copied!';
setTimeout(() => { button.textContent = 'Copy'; }, 2000);
} else {
button.textContent = 'Copy failed — use Ctrl+C';
}
});
}
For React, wrap copyFormattedHtml in a useCallback and track status with useState. The pattern is identical — keep the await in the click handler body.
Failure Modes and Recovery
| DOMException name | Trigger | Minimal fix |
|---|---|---|
NotAllowedError |
Write called outside a user gesture, or permission blocked | Fall through to writeText tier; show a manual-copy prompt |
SecurityError |
Non-secure origin, cross-origin iframe without allow="clipboard-write", or Permissions Policy block |
Cannot recover programmatically — show a diagnostic message |
DataError |
Invalid MIME type string or malformed Blob in ClipboardItem |
Validate MIME strings; fall back to writeText |
NotAllowedError is the most common failure in production. Design your permission flows and progressive enhancement strategy to treat it as a normal operating state rather than an exception — particularly on mobile Safari where the gesture-timing window is shorter than on desktop.
When running inside a sandboxed iframe, add allow="clipboard-write" to the <iframe> tag or delegate the write to the parent window via postMessage. Without the attribute, the SecurityError is unrecoverable from inside the frame.
Browser and Platform Caveats
iOS Safari (16.4+): ClipboardItem is available but the user-activation window is roughly 100ms from the gesture. A write that fires later than that — for example after an image decode or a fetch round-trip — fails with NotAllowedError. Precompute the payload before the gesture fires, then call write() synchronously in the handler. For older iOS versions that lack ClipboardItem, navigator.clipboard.writeText() works reliably. The legacyExecCommandFallback also works on iOS provided the <textarea> is not display:none — position:fixed off-screen is fine.
Android Chrome: clipboard-write is auto-granted on gesture; no permission prompt appears. PWA installed apps and Custom Tabs behave identically to the browser. Background Service Workers have no access to navigator.clipboard.
Firefox pre-126: ClipboardItem is absent. typeof ClipboardItem === 'undefined' returns true and the detectClipboardTier function returns 'text-only', routing correctly to writeText. Firefox 126+ ships full ClipboardItem support for text/html, text/plain, and image/png without flags.
Cross-origin iframes and WebViews: Clipboard access is blocked by default. React Native, Flutter, and Electron WebViews expose navigator.clipboard only when the host application grants the relevant native permission. Check window.isSecureContext — WebViews in non-HTTPS contexts will have a false value unless the host explicitly marks the context secure. For large payloads that exceed clipboard size limits, consider the File System Access API as a companion download channel.
Frequently Asked Questions
Why does navigator.clipboard.write() fail silently in PWAs?
PWAs require the document to be focused and visible. The Clipboard API rejects when document.hasFocus() is false — for example during a background sync handler or a push notification callback. Trigger copy only from a visible UI element while the app is in the foreground.
Can I copy formatted HTML without a user gesture?
No. Every modern browser enforces transient user activation to block silent data exfiltration. Background clipboard writes are rejected at the engine level regardless of origin. A visible Copy button that users click explicitly is the only supported path.
How do I handle large HTML tables or styled documents?
Clone the DOM node to avoid live-layout interference, strip scripts and external resources, serialise to a string, and wrap in Blob instances for the ClipboardItem. For payloads above roughly 5 MB, break content into sections or offer a download via the File System Access API instead.
Related
- Mastering the Clipboard API for Rich Text — parent guide covering dual-MIME payloads, read operations, and the full browser support table
- Async Payload Formatting for Native APIs — cross-cutting pattern for constructing payloads correctly inside user-activation constraints
- Handling Permission Denials Gracefully — recovery patterns when
NotAllowedErrorfires and the user has blocked clipboard access