Copying Share URLs with the Async Clipboard API
This guide is part of Copy-to-Clipboard Fallback Patterns, which itself is part of Permission Flows & Progressive Enhancement.
When navigator.share is unavailable, copying the share URL with navigator.clipboard.writeText(url) is the cleanest fallback on any evergreen browser — but only if you satisfy its three preconditions: a secure context, a live user gesture, and a focused document. Miss any one and the promise rejects with NotAllowedError. This guide walks the full path from feature gate to success toast, including the clipboard-write permission query and the exact recovery for each failure mode.
Feature Detection Gate
Run one synchronous gate before offering the copy. Optional chaining keeps the expression safe when navigator.clipboard is undefined on non-secure origins, and the typeof ... === 'function' check confirms writeText is actually callable rather than merely present.
// clipboard-gate.js
export function canCopyUrl() {
return (
window.isSecureContext === true &&
typeof navigator.clipboard?.writeText === 'function'
);
}
Call canCopyUrl() before rendering the copy button and again immediately before the write. If it returns false, do not attempt the async path at all — route to the execCommand copy fallback, which is the only path that reaches non-secure contexts.
Solution Walkthrough
Step 1 — Optionally query the clipboard-write permission
The Permissions API lets you inspect the clipboard-write state without triggering a prompt. This is diagnostic only — you do not need a granted result to write, because Chromium auto-grants on gesture and Safari and Firefox never prompt for writes. Use it to disable UI or log why a copy might be blocked. Guard the call, since clipboard-write is not a queryable name in every browser.
// clipboard-permission.js
export async function readClipboardWriteState() {
if (typeof navigator.permissions?.query !== 'function') {
return 'unsupported';
}
try {
const status = await navigator.permissions.query({ name: 'clipboard-write' });
return status.state; // 'granted' | 'prompt' | 'denied'
} catch {
// Firefox and Safari throw on the unknown 'clipboard-write' name
return 'unsupported';
}
}
Treat any result other than 'denied' as a green light to attempt the write. A 'denied' state means the user explicitly blocked clipboard access for the origin, so you can surface a manual-copy affordance up front instead of letting the write fail.
Step 2 — Write the URL inside the user gesture
Bind the write directly to the click handler and await it. The await does not break user activation because it sits on the synchronous portion of the gesture. Build a clean, validated URL string first so you never place a non-web scheme on the clipboard.
// copy-url.js
import { canCopyUrl } from './clipboard-gate.js';
export async function copyShareUrl(rawUrl = window.location.href) {
if (!canCopyUrl()) {
return { ok: false, reason: 'unsupported' };
}
const url = new URL(rawUrl, window.location.href).href; // normalise + validate
try {
await navigator.clipboard.writeText(url);
return { ok: true };
} catch (error) {
return { ok: false, reason: error.name }; // typically 'NotAllowedError'
}
}
Constructing a URL object normalises relative paths and throws synchronously on a malformed value, so a bad input fails loudly here rather than silently copying garbage. The share-first orchestration that calls this — native share, then this copy, then the legacy path — lives in the copy-to-clipboard fallback patterns guide.
Step 3 — Catch NotAllowedError and confirm success
NotAllowedError is the one rejection you will see in production on a secure origin, and it almost always means the document lost focus or the gesture chain was broken. Wire the caller so success drives an accessible confirmation and failure routes onward. Announce the result through an aria-live region so screen-reader users hear it too.
// copy-button.js
import { copyShareUrl } from './copy-url.js';
export function initCopyButton(button, liveRegion, onFallback) {
button.addEventListener('click', async () => {
const result = await copyShareUrl(button.dataset.url);
if (result.ok) {
liveRegion.textContent = 'Copied! Link is on your clipboard.';
button.classList.add('is-copied');
return;
}
if (result.reason === 'NotAllowedError') {
// Retry once after reclaiming focus, then hand off
window.focus();
}
onFallback(button.dataset.url); // execCommand or manual-select rung
});
}
Keep the confirmation short and factual. A single window.focus() retry can recover the case where a transient blur caused the rejection, but do not loop — repeated futile writes only frustrate the user. If it still fails, the onFallback handoff takes over.
Failure Modes and Recovery
| Error / symptom | Cause | Minimal fix |
|---|---|---|
navigator.clipboard is undefined |
Page served over http:// (non-secure context) |
Gate with canCopyUrl(); route to execCommand fallback |
NotAllowedError immediately |
Write called outside a user gesture (timer, load, promise callback) | Move the call into the click/keydown handler |
NotAllowedError intermittently |
Document not focused (devtools focused, iframe stole focus, alert() fired) |
Call window.focus() once, then retry or hand off |
NotAllowedError persistently |
clipboard-write set to denied for the origin |
Query permission up front; show manual-copy UI |
| Copy succeeds but paste is empty | rawUrl resolved to an empty string |
Validate with new URL(...) before writing |
Browser and Platform Caveat
navigator.clipboard.writeText is broadly supported: Chrome 66+, Edge 79+, Firefox 63+, and Safari 13.1+, all requiring a secure context. Crucially it works on desktop Firefox, where navigator.share does not exist, which is precisely why it is the primary share fallback there. iOS Safari enforces a tighter gesture window than desktop — a write that runs more than a moment after the tap can be dropped — so keep the call on the synchronous path of the handler. On plain http:// origins the API is absent entirely, which is the boundary where you must fall through to the legacy execCommand path.
FAQ
Why does navigator.clipboard.writeText throw NotAllowedError over HTTPS?
Over HTTPS the two remaining causes are a missing user gesture and a document that does not have focus. The write must run synchronously inside a click or keydown handler, and the tab must be focused at that moment. A blurred document, a focused devtools pane, or a call deferred with setTimeout all produce NotAllowedError.
Do I need to query the clipboard-write permission before writing?
No. In Chromium browsers clipboard-write is auto-granted on a user gesture, and Safari and Firefox do not surface a prompt for writes at all. The Permissions API query is useful for diagnostics and to disable UI when state is denied, but the write itself does not require it.
Can I copy to the clipboard without a user gesture?
No. Every major browser requires user activation for clipboard writes. A copy fired from a timer, a promise callback, or on page load without a preceding interaction is rejected with NotAllowedError. Bind the write directly to the gesture event.
What happens to writeText on an http:// page?
On a non-secure origin navigator.clipboard is undefined, so navigator.clipboard?.writeText short-circuits to undefined and the gate fails before any call. There is no exception to catch — you route straight to the execCommand fallback instead.