Skip to content

Clipboard Copy with Universal Fallback

navigator.clipboard.writeText() only exists in a secure context. On plain http://, on file://, or when a permission is denied, it is absent or rejects. A copy feature that dead-ends at a "clipboard unavailable" message fails exactly where a self-contained artifact is most likely to run: opened from the file system or served over plain HTTP. Degrade through three tiers so a copy always succeeds, or at worst leaves the text selected for a manual copy.

When to use

Use this pattern whenever code copies text to the clipboard and cannot assume a secure context: single-file HTML artifacts, prototypes opened from disk, tools served over plain HTTP, or anything a human might run locally.

The pattern

Try each tier in order and stop at the first that works:

  1. async navigator.clipboard.writeText() (secure context);
  2. synchronous document.execCommand("copy") through a throwaway off-screen <textarea> (covers plain http:// and file://);
  3. a small dialog with the text pre-selected and a Copy button (plus a "press ⌘C/Ctrl+C" hint), so there is always a manual path.
javascript
async function copy(text, okMessage) {
  if (navigator.clipboard && navigator.clipboard.writeText) {
    try {
      await navigator.clipboard.writeText(text);
      toast(okMessage);
      return;
    } catch (error) {
      // Secure-context API rejected (denied permission): drop a tier.
    }
  }
  // No secure-context API, or it rejected (plain http, file://): drop a tier.
  if (execCopy(text)) toast(okMessage);
  else showCopyBox(text);
}

Tier 2: synchronous execCommand

execCommand("copy") is deprecated but remains the only synchronous copy path on non-secure origins. Select the text in an off-screen <textarea>, copy, then remove it:

javascript
function execCopy(text) {
  const textarea = document.createElement("textarea");
  textarea.value = text;
  // Park it off-screen rather than styling in JS; a class keeps CSS in CSS.
  textarea.className = "copy-source";
  textarea.setAttribute("readonly", "");
  document.body.appendChild(textarea);
  textarea.select();
  textarea.setSelectionRange(0, text.length);
  let ok = false;
  try {
    ok = document.execCommand("copy");
  } catch (error) {}
  textarea.remove();
  return ok;
}
css
.copy-source {
  position: fixed;
  top: -1000px;
  opacity: 0;
}

Tier 3: manual copy dialog

When even execCommand is blocked, show the text already selected with a Copy button that retries execCommand, falling back to a keyboard hint:

javascript
function showCopyBox(text) {
  const box = document.querySelector(".copy-box");
  const textarea = box.querySelector(".copy-text");
  textarea.value = text;
  box.showPopover();
  textarea.focus();
  textarea.select();
}

Detect the platform so the hint names the right key:

javascript
const isMac = /Mac|iP(hone|ad|od)/.test(
  navigator.platform || navigator.userAgent || "",
);
const copyKey = isMac ? "⌘C" : "Ctrl+C";

Trade-offs

  • execCommand is deprecated but irreplaceable on non-secure origins, where the async Clipboard API does not exist. Keep it as the middle tier rather than removing it.
  • Tier 3 always succeeds at something. Even if no programmatic copy works, the text is selected and ready, so the human can copy it by hand. The feature never dead-ends.

See also

  • Popover API for Overlays: Render the fallback dialog and the confirmation toast in the top layer without z-index juggling.