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

execCommand to Clipboard API migration flow Four sequential steps: detect tier returns modern/text-only/legacy/unavailable; sanitise HTML strips scripts and event handlers; build ClipboardItem pairs text/html with text/plain blobs; navigator.clipboard.write() is called inside the user gesture handler. Fallback arrows drop from text-only to writeText and from legacy to execCommand textarea. 1. Detect tier modern / text-only legacy / unavailable 2. Sanitise HTML strip <script>, on* attrs derive plain-text copy 3. Build ClipboardItem text/html Blob + text/plain Blob 4. clipboard .write([item]) inside user gesture text-only tier → writeText(plain) legacy / unavailable → textarea + execCommand('copy')

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:noneposition: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.