execCommand Copy Fallback for Legacy Browsers
This guide is part of Copy-to-Clipboard Fallback Patterns, which itself is part of Permission Flows & Progressive Enhancement.
When neither navigator.share nor navigator.clipboard is available — a non-secure origin, an in-app WebView, or a pre-2019 browser — the deprecated document.execCommand('copy') is the only remaining way to place a share URL on the clipboard. It works by injecting an offscreen <textarea>, selecting its text, and issuing a synchronous copy command. The technique is fiddly and easy to get subtly wrong, so this guide nails down the ordering constraints, the iOS selection quirk, and the failure handling.
Feature Detection Gate
execCommand is a last resort, not a first choice. Reach it only after the async Clipboard API has been ruled out. The one thing worth confirming is that the method exists at all, since it is absent in some sandboxed contexts.
// exec-command-gate.js
export function canUseExecCommandCopy() {
return typeof document.execCommand === 'function';
}
Do not gate this on window.isSecureContext — the whole reason to keep execCommand around is that it still runs on non-secure origins where the async clipboard is undefined. The orchestration that decides when to reach this rung lives in the copy-to-clipboard fallback patterns guide.
Solution Walkthrough
Step 1 — Create an offscreen textarea
Build a <textarea>, set its value to the URL, mark it readonly so mobile keyboards do not pop up, and position it offscreen without hiding it. The element must remain a rendered form field — display:none disqualifies it from selection on several platforms — so use position:fixed with opacity:0 instead.
// build-textarea.js
export function createOffscreenTextarea(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.setAttribute('aria-hidden', 'true');
textarea.style.cssText = [
'position:fixed',
'top:0',
'left:-9999px',
'width:1px',
'height:1px',
'padding:0',
'border:0',
'opacity:0',
'pointer-events:none',
].join(';');
return textarea;
}
Keeping the element tiny and pointer-events:none prevents any layout shift or accidental interaction while it is briefly attached. It exists only long enough to hold the selection for the copy command.
Step 2 — Select synchronously and copy
Append the textarea, select its contents, and call execCommand — all within the same synchronous tick as the originating click. This is the critical constraint: execCommand reads the live user-activation state, so no await or setTimeout may intervene. The setSelectionRange call is the iOS fix, since select() alone does not create a copyable selection there.
// exec-command-copy.js
import { canUseExecCommandCopy } from './exec-command-gate.js';
import { createOffscreenTextarea } from './build-textarea.js';
export function copyWithExecCommand(text) {
if (!canUseExecCommandCopy()) {
return { ok: false, reason: 'unsupported' };
}
const textarea = createOffscreenTextarea(text);
const previousFocus = document.activeElement;
document.body.appendChild(textarea);
let ok = false;
try {
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, text.length); // iOS Safari requires an explicit range
ok = document.execCommand('copy') === true;
} catch {
ok = false;
} finally {
document.body.removeChild(textarea);
if (previousFocus instanceof HTMLElement) previousFocus.focus();
}
return { ok, reason: ok ? null : 'exec-command-false' };
}
Restoring previousFocus in the finally block returns focus to the button the user clicked, which keeps keyboard navigation sensible. Because the entire body of the function is synchronous, the user activation from the click is still valid when execCommand runs.
Step 3 — Report the result and hand off on failure
execCommand('copy') returns a boolean: true when the browser performed the copy, false when it refused. Never assume success. Announce a confirmation through an aria-live region on true, and on false reveal the URL so the user can select and copy it manually.
// legacy-copy-button.js
import { copyWithExecCommand } from './exec-command-copy.js';
export function initLegacyCopy(button, liveRegion, manualCopyEl) {
button.addEventListener('click', () => {
const result = copyWithExecCommand(button.dataset.url);
if (result.ok) {
liveRegion.textContent = 'Copied! Link is on your clipboard.';
return;
}
// Last rung: expose the raw URL for manual selection
manualCopyEl.value = button.dataset.url;
manualCopyEl.hidden = false;
manualCopyEl.focus();
manualCopyEl.select();
liveRegion.textContent = 'Copy failed — the link is selected below, press Ctrl+C or long-press to copy.';
});
}
Note there is no await anywhere in this handler — execCommand is synchronous, and keeping the whole path synchronous is what preserves the user activation the copy depends on.
Failure Modes and Recovery
| Error / symptom | Cause | Minimal fix |
|---|---|---|
execCommand returns false |
No valid selection, or command refused | Reveal manual-copy UI with the URL preselected |
| Nothing copied on iOS | select() used without setSelectionRange |
Add setSelectionRange(0, text.length) after select() |
| Nothing copied, element hidden | Textarea styled display:none |
Use position:fixed; opacity:0 offscreen instead |
| Copy rejected despite a click | An await/setTimeout ran before execCommand |
Keep create-select-copy fully synchronous in the handler |
| Mobile keyboard flashes | Textarea not readonly |
Set the readonly attribute before appending |
Browser and Platform Caveat
document.execCommand('copy') is deprecated and no longer part of the active editing spec, but every shipping browser — Chrome, Edge, Firefox, and Safari, on desktop and mobile — still implements it as a legacy alias, and that is unlikely to change soon because too much existing code depends on it. It is the broadest-reach copy mechanism available: it runs in non-secure http:// contexts and in-app WebViews where the async Clipboard API is entirely absent, which is exactly why it stays as the last automated rung before a manual-select prompt. iOS Safari is the fussiest target, demanding a visible form element and an explicit selection range. Treat this path purely as a safety net beneath the async Clipboard API, never as your primary copy method.
FAQ
Why must the execCommand copy be synchronous?
document.execCommand reads the current user-activation state at the moment it runs. If you await anything between the click and the execCommand call, the activation is consumed and the copy is rejected. The whole create-select-copy-remove sequence must sit in the synchronous portion of the event handler.
Why does select() not work on iOS Safari?
On iOS, textarea.select() alone does not establish a selection that execCommand can copy. You must call setSelectionRange(0, value.length) after select(), and the textarea must be a visible form element — position:fixed offscreen with opacity:0 works, but display:none does not.
Is it safe to still ship execCommand in 2026?
Yes, as a last resort only. execCommand('copy') is deprecated and removed from the active spec, but every shipping browser still implements it as a legacy alias. It is the only copy path that works in non-secure contexts and pre-2019 browsers, so keep it behind the async Clipboard API rather than as your primary method.
What does execCommand returning false mean?
A false return means the browser refused or could not perform the copy — usually because there was no valid selection, the element was not focusable, or the command ran outside a user gesture. Treat false as a failure and reveal a manual-copy UI so the user can select the URL themselves.