Mastering the Clipboard API for Rich Text

This guide is part of Native Device Integration Patterns.

Modern web applications increasingly need copy-paste workflows that preserve formatting across desktop editors, email clients, mobile browsers, and PWA environments. Without a deliberate dual-format strategy, copying rich content produces either raw HTML blobs in plain-text fields or formatting-stripped strings in rich editors — both are failures that erode user trust in the application. This guide covers the full implementation lifecycle: secure-context validation, dual-MIME ClipboardItem construction, user-gesture binding, error boundaries, and a three-tier fallback chain.


Clipboard API write flow Decision flow from user gesture through feature detection to ClipboardItem write, with fallback paths to writeText and legacy execCommand User gesture (click / keydown) Feature detection isSecureContext + clipboard ClipboardItem text/html + text/plain navigator.clipboard.write() async, returns Promise ClipboardItem unavailable? → writeText() fallback No clipboard API? → execCommand fallback

Problem Framing

Applications that use document.execCommand('copy') without any modern path fail in two ways: the command is deprecated and already removed from the spec (browsers ship it as a legacy alias, not a guarantee), and it cannot write structured multi-MIME data at all — only whatever text happens to be selected in the DOM at the moment of the call. Teams that skip the ClipboardItem path also lose access to read operations, permission-status checks, and the ability to write image data.

The async payload formatting patterns that apply to the Web Share API apply here too: you cannot construct a clipboard payload synchronously and fire it from a setTimeout. The browser discards clipboard writes that do not originate from a user-activation-flagged call stack.


Prerequisites

  • HTTPS or localhostnavigator.clipboard is undefined on plain HTTP origins. window.isSecureContext must be true.
  • No special browser flags — the Clipboard API is shipping-stable in all evergreen browsers. No flags are needed in production; Permissions Policy on the embedding origin must not block clipboard-write.
  • Permissions: clipboard-write is granted automatically on user gesture in Chrome and Edge. Safari requires no explicit permission for write. Firefox does not display a permission prompt for write operations but does for read. clipboard-read requires an explicit prompt in all Chromium-based browsers.

Browser Support Snapshot

Feature Chrome Edge Firefox Safari
navigator.clipboard.writeText() 66 79 63 13.1
ClipboardItem + clipboard.write() 76 79 126 13.1
clipboard.read() (rich) 86 86 126 13.1
clipboard-write auto-grant on gesture Yes Yes Yes Yes
clipboard-read requires prompt Yes Yes Yes Yes
Works in cross-origin iframes No¹ No¹ No¹ No¹

¹ Cross-origin iframes must have allow="clipboard-write" in the <iframe> tag and will still be blocked in some browsers.

Firefox note: ClipboardItem and multi-MIME clipboard.write() landed in Firefox 126 (May 2024). Any Firefox build before that supports only navigator.clipboard.writeText(). Detection via typeof ClipboardItem !== 'undefined' before attempting multi-MIME writes is mandatory.


Step-by-Step Implementation

Step 1 — Validate the Environment

Every implementation begins with a guard. Check secure context first, then API availability, then ClipboardItem availability as a separate level. Failing fast at each level allows the caller to route to the appropriate fallback without attempting operations that will throw.

/**
 * Returns the capability tier of the current runtime for clipboard writes.
 * @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';
}

This function is synchronous and safe to call during component mount. Store the result and use it to decide which copy controls to render — for example, hiding an “Export as Rich Text” button entirely when the tier is 'legacy' or 'unavailable'.

Step 2 — Construct a Dual-MIME ClipboardItem

A ClipboardItem maps MIME-type strings to Blob instances. Including both text/html and text/plain ensures that applications which cannot consume HTML — terminals, plain-text editors, older email clients — receive a usable paste value.

Sanitise the HTML before creating the blob. Unsanitised user-generated content passed through text/html can deliver XSS payloads to any application that interprets pasted HTML. Strip script tags, inline event handlers, and javascript: URLs at a minimum. For production use, run the string through a server-side sanitiser or a client-side library such as DOMPurify.

/**
 * Builds a ClipboardItem from HTML and plain-text strings.
 * @param {string} html   Sanitised HTML markup
 * @param {string} plain  Plain-text equivalent
 * @returns {ClipboardItem}
 */
export function buildRichTextPayload(html, plain) {
  const htmlBlob = new Blob([html], { type: 'text/html' });
  const plainBlob = new Blob([plain], { type: 'text/plain' });
  return new ClipboardItem({
    'text/html': htmlBlob,
    'text/plain': plainBlob,
  });
}

Keep the plain-text version as a genuine human-readable representation of the content, not a serialised dump of the HTML. Users pasting into plain-text contexts should see a clean, formatted equivalent — paragraph breaks preserved, table data tab-separated, list items on separate lines.

Step 3 — Bind the Write Call to a User Gesture

navigator.clipboard.write() must be called from within a user-activated event handler. The browser’s user-activation mechanism enforces this: activation state is consumed and cannot be stored in a variable or deferred to a microtask. The call must sit in the synchronous portion of the click handler, with await permitted because async functions do not break the activation chain.

/**
 * Copies rich text to the clipboard. Must be called from a user event handler.
 * @param {string} html   Sanitised HTML markup
 * @param {string} plain  Plain-text equivalent
 * @returns {Promise<{success: boolean, method: string}>}
 */
export async function copyRichText(html, plain) {
  const tier = detectClipboardTier();

  if (tier === 'modern') {
    try {
      const item = buildRichTextPayload(html, plain);
      await navigator.clipboard.write([item]);
      return { success: true, method: 'clipboard-item' };
    } catch (err) {
      if (err instanceof DOMException && (err.name === 'NotAllowedError' || err.name === 'SecurityError')) {
        // Permission denied — fall through to text-only path
      } else {
        return { success: false, method: 'clipboard-item', error: err.name };
      }
    }
  }

  if (tier === 'modern' || tier === 'text-only') {
    try {
      await navigator.clipboard.writeText(plain);
      return { success: true, method: 'writeText' };
    } catch {
      // Fall through to legacy path
    }
  }

  return legacyExecCommandFallback(plain);
}

Attach this function directly to a click or keydown handler on the copy button. Do not wrap the call in a setTimeout or trigger it from a fetch callback — both break the user-activation chain.

Step 4 — Implement the Legacy Fallback

The execCommand path is a last resort for scenarios where the Clipboard API is unavailable — primarily non-secure HTTP contexts and very old browser environments. It works by injecting a <textarea> into the document, programmatically selecting its text, and issuing the copy command. Clean up the DOM node immediately regardless of success or failure.

/**
 * Last-resort clipboard write using the deprecated execCommand path.
 * @param {string} text  Plain text to copy
 * @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); // iOS requires explicit range
    success = document.execCommand('copy');
  } catch {
    // execCommand not available
  } finally {
    document.body.removeChild(ta);
  }

  return { success, method: 'legacy' };
}

ta.setSelectionRange(0, ta.value.length) is the iOS-specific fix: textarea.select() alone does not select text in Safari on iOS 12 and earlier without the explicit range.

Step 5 — Surface Feedback to the User

Return a result object from each path and use it to drive UI feedback. Do not silently swallow failures — a user who clicks “Copy” and sees no indication that it failed will attempt to paste and discover the error at the worst possible moment.

/**
 * Wires a copy button to the rich-text clipboard flow with toast feedback.
 * @param {HTMLButtonElement} button
 * @param {() => {html: string, plain: string}} getContent
 */
export function attachCopyButton(button, getContent) {
  button.addEventListener('click', async () => {
    const { html, plain } = getContent();
    const result = await copyRichText(html, plain);

    if (result.success) {
      showToast(`Copied (${result.method})`);
    } else {
      showToast('Copy failed — try selecting and using Ctrl+C', { error: true });
    }
  });
}

Payload Validation and Error Boundaries

Three DOMException names require distinct handling:

Exception Cause Recovery
NotAllowedError Write called outside user gesture, or permission denied Activate legacyExecCommandFallback; surface a manual copy prompt
SecurityError Origin mismatch, non-secure context, or Permissions Policy block Show diagnostic message; cannot recover programmatically
DataError Invalid MIME type or malformed Blob in ClipboardItem Validate MIME strings before constructing the item; fall back to writeText

NotAllowedError is by far the most common in production. When a user grants clipboard permission once, Chrome remembers it for the session. Safari and Firefox do not persist clipboard-write grants — the write must occur within the same user-activation event every time.

When designing your permission flows and progressive enhancement strategy, plan for NotAllowedError as a normal operating state rather than an exceptional one — especially on mobile Safari, where the clipboard-write prompt does not appear and the gesture timing constraint is stricter.


Implementing Read Operations

Reading from the clipboard is more restricted than writing. navigator.clipboard.read() triggers a browser-native permission prompt for clipboard-read in Chromium-based browsers. Safari permits read operations without a prompt but only within a user gesture.

/**
 * Reads HTML content from the clipboard, falling back to plain text.
 * Must be called from a user event handler.
 * @returns {Promise<{html: string | null, plain: string | null}>}
 */
export async function readRichText() {
  if (!window.isSecureContext || typeof navigator.clipboard === 'undefined') {
    return { html: null, plain: null };
  }

  try {
    const items = await navigator.clipboard.read();
    let html = null;
    let plain = null;

    for (const item of items) {
      if (item.types.includes('text/html')) {
        const blob = await item.getType('text/html');
        html = await blob.text();
      }
      if (item.types.includes('text/plain')) {
        const blob = await item.getType('text/plain');
        plain = await blob.text();
      }
    }

    return { html, plain };
  } catch (err) {
    if (err instanceof DOMException && err.name === 'NotAllowedError') {
      // User denied the permission prompt — try plain-text fallback
      try {
        const plain = await navigator.clipboard.readText();
        return { html: null, plain };
      } catch {
        return { html: null, plain: null };
      }
    }
    return { html: null, plain: null };
  }
}

Always sanitise HTML retrieved from clipboard.read() before inserting it into the DOM. The clipboard can contain HTML pasted from any source — do not trust it.


Platform Gotchas

iOS Safari

  • navigator.clipboard.write() requires a direct user gesture. The gesture timeout is shorter than on desktop — a write that occurs more than ~100ms after the click event fires may fail.
  • ClipboardItem is available in iOS 16.4+. Older iOS versions need the writeText path.
  • execCommand('copy') works in iOS Safari but only when text is selected in a visible (not display:none) form element. position:fixed off-screen is acceptable; display:none is not.

Android Chrome

  • Clipboard permission (clipboard-write) is auto-granted on gesture. No prompt appears.
  • PWA installed apps and Custom Tabs behave identically to the browser for clipboard operations.
  • Background Service Workers do not have access to navigator.clipboard.

Desktop Firefox (pre-126)

  • ClipboardItem is not available. navigator.clipboard.writeText() works for plain text.
  • clipboard.read() is behind a dom.events.asyncClipboard.read preference flag in builds before 116.

WebViews (React Native, Flutter, Electron)

WebViews expose navigator.clipboard only when the host app grants the android.permission.WRITE_EXTERNAL_STORAGE or equivalent native permission. Check window.isSecureContext — WebViews in non-HTTPS contexts will have a false value unless the host app explicitly marks the context secure. For large file payloads in native shell apps, consider integrating the File System Access API as a companion channel alongside clipboard writes.


Testing and Verification

Chrome DevTools — Application tab

Open DevTools → Application → Permissions to inspect the current clipboard-read and clipboard-write grant state. The “Clear site data” button resets all permissions including clipboard for a clean test run.

Simulating permission denial

In Chrome, use the lock icon in the address bar to set Clipboard to “Block” and reload the page. Trigger your copy path — the handler must receive NotAllowedError and activate the fallback. Verify with console.log that the fallback path executed and that the user saw the expected feedback.

Cross-browser checklist

Physical device checklist

  1. Tap the copy button on an iOS device (do not use a long-press — that is not a recognised gesture).
  2. Open Notes and paste. Confirm rich text formatting is preserved.
  3. Open a plain-text field (URL bar, Terminal via SSH) and paste. Confirm the fallback plain-text value is correct.
  4. Rotate to landscape and repeat to rule out layout-triggered reflows that might delay the write call.

Frequently Asked Questions

Why does navigator.clipboard.write() throw NotAllowedError even over HTTPS?

The Clipboard API requires a direct user-initiated gesture — a click, tap, or keydown. Background tasks, timers, and programmatic calls without a live user interaction are blocked by every major browser’s security model, regardless of origin. Ensure the await navigator.clipboard.write(...) call is within the synchronous portion of the event handler, not deferred via setTimeout or a fetch callback.

How do I preserve rich text formatting when writing to the clipboard?

Construct a ClipboardItem with both text/html and text/plain MIME types. Target applications pick the richest format they support and degrade to plain text automatically when needed. Omitting text/plain from the item causes paste failures in applications that only accept plain text.

What is the recommended fallback when ClipboardItem is unavailable?

Use navigator.clipboard.writeText() for plain text when ClipboardItem is unavailable. If that also fails, create a hidden <textarea>, select its content with setSelectionRange, and call document.execCommand('copy'). Remove the element immediately after via finally.

Can I read rich text from the clipboard programmatically?

Yes, using navigator.clipboard.read(). Read operations require explicit user permission in Chromium browsers and are restricted to prevent clipboard snooping. navigator.clipboard.readText() is more broadly supported and does not require ClipboardItem. Always sanitise HTML content retrieved from clipboard reads before injecting it into the DOM.

Does the Clipboard API work in a Service Worker?

No. navigator.clipboard is not available in Service Worker contexts — clipboard access requires a browsing context with a window object. Clipboard operations must occur in the page’s main thread or a Dedicated Worker with a visible document attached.