Permission Flows & Progressive Enhancement
The Web Share API and related native device capabilities require explicit user consent and a verified secure context before a single share sheet call can succeed. This reference covers the full permission and progressive-enhancement architecture: from window.isSecureContext validation and gesture-gated invocation through to a layered fallback chain that keeps sharing functional on every browser, OS, and network condition.
Architecture Overview
Every integration in this section is built on four non-negotiable mandates:
- Secure context first —
window.isSecureContextmust betruebefore any capability check or API call. HTTPS is required in production;localhostis treated as secure for development. - Feature detection, not browser sniffing —
navigator.shareandnavigator.canShare(payload)are the canonical gates. Version strings and user-agent parsing break silently across rapid browser release cycles. - Progressive enhancement by default — the fallback path (clipboard,
mailto:,sms:, QR code) is not an error state; it is a first-class code path exercised on every environment where native share is absent or denied. - Gesture-gated invocation —
navigator.share()requires transient activation. Calling it outside a direct user interaction throwsNotAllowedErrorbefore the OS share sheet is ever consulted.
API Surface Reference
| Method / Property | Signature | Returns | Throws |
|---|---|---|---|
navigator.share() |
share(data?: ShareData): Promise<void> |
Promise<void> resolves on success |
NotAllowedError (no gesture / denied), AbortError (user dismissed), DataError (no valid field), TypeError (secure context missing) |
navigator.canShare() |
canShare(data?: ShareData): boolean |
true if payload is shareable |
Does not throw; returns false for invalid/unsupported payloads |
window.isSecureContext |
Read-only boolean | true on HTTPS/localhost |
— |
ShareData.files |
File[] |
— | Only valid when navigator.canShare({ files }) returns true; file sharing is not universally supported |
Permissions.query() |
query({ name: 'clipboard-write' }): Promise<PermissionStatus> |
PermissionStatus |
TypeError for unrecognised permission names |
ShareData fields — title, text, url, files — are all optional individually, but at least one must be present and non-empty, otherwise navigator.canShare() returns false and navigator.share() throws DataError.
Secure Context Validation
Understanding secure context requirements is the prerequisite step before any code in this section runs. window.isSecureContext is the runtime gate; it returns false on plain HTTP origins (except localhost) and on any document served in an opaque origin (e.g. sandboxed iframes without allow-same-origin).
Mixed content complicates staging environments. A page served over HTTPS that loads a script or stylesheet over HTTP triggers mixed-content blocking, which can disable APIs in stricter browser configurations even when isSecureContext is technically true. Always mirror production TLS on staging and validate isSecureContext programmatically at module initialisation, not just at call time.
/**
* Module-level secure-context guard.
* Call once during app init — not inside the share handler.
*/
export function assertSecureContext() {
if (!window.isSecureContext) {
throw new Error(
'Web Share API requires a secure context (HTTPS or localhost). ' +
`Current origin: ${location.origin}`
);
}
}
Configuring HTTPS for local development walks through mkcert and Vite/webpack proxy setups that keep isSecureContext true during development without deploying to a remote server.
Feature Detection & Capability Mapping
Browser support for navigator.share varies substantially across platforms. The presence check alone is insufficient — iOS Safari supports navigator.share but rejects files on many versions; Android Chrome accepts files but enforces MIME-type allow-lists that differ from desktop Chrome. Always check navigator.canShare(payload) with the exact payload you intend to send before rendering the native share button.
/**
* Returns a structured capability snapshot for the current runtime.
* Cache this result for the session — capabilities do not change mid-session.
*/
export function evaluateShareCapabilities(testPayload) {
if (!window.isSecureContext) {
return { supported: false, reason: 'insecure_context' };
}
if (typeof navigator.share !== 'function') {
return { supported: false, reason: 'api_absent' };
}
const payload = testPayload ?? {
title: 'test',
text: 'test',
url: window.location.origin
};
const canShare = navigator.canShare?.(payload) ?? false;
return {
supported: true,
canShare,
supportsFiles: navigator.canShare?.({ files: [new File([''], 'test.txt')] }) ?? false
};
}
Map MIME types and file extensions before constructing file payloads. Different operating systems expose different share targets at runtime. For file sharing, query navigator.canShare({ files: [file] }) at the point of file selection rather than at app initialisation, since the result depends on the specific file type.
Contextual Permission Prompts
The single largest variable in share permission grant rates is trigger timing. Permission prompts — and the OS share sheet itself — must be initiated by an explicit, synchronous user gesture. Designing contextual permission prompts that communicate the value of sharing before the share sheet appears reduces abandonment and builds user trust.
Pre-prompt UI patterns work because they establish intent before the OS interrupts with a system-level dialogue. A brief “Share this result?” affordance placed contextually next to the shareable content outperforms a generic toolbar share icon that appears before the user has seen the value of sharing.
Never chain permission requests within a single handler. If your flow requires both share permission and clipboard access, resolve the primary action first. Transient activation is consumed by the first async API call — a second await on a separate permission-requiring API in the same handler will fail.
/**
* Gesture-gated share handler with serialised fallback.
* Must be invoked directly from a user event handler — not from setTimeout or a resolved Promise chain.
*/
export async function handleShareWithFallback(payload) {
const caps = evaluateShareCapabilities(payload);
if (caps.supported && caps.canShare) {
try {
await navigator.share({
title: payload.title ?? document.title,
text: payload.text ?? '',
url: payload.url ?? location.href
});
return { status: 'shared' };
} catch (error) {
if (error.name === 'AbortError') {
// User dismissed the share sheet — not an error, do nothing
return { status: 'cancelled' };
}
if (error.name === 'NotAllowedError') {
// No gesture, or share blocked by browser policy — fall through to clipboard
}
// DataError, TypeError, or unknown — fall through
}
}
// Fallback: clipboard copy (also requires gesture context, still valid here)
try {
await navigator.clipboard.writeText(payload.url ?? location.href);
return { status: 'clipboard_copy' };
} catch {
return { status: 'fallback_required' };
}
}
Permission Denial Recovery
When navigator.share() throws NotAllowedError or when navigator.canShare() returns false, the user must not reach a dead end. Handling permission denials gracefully covers the full recovery architecture: informative UI messaging, persisting denial state to sessionStorage to avoid repeated prompt loops, and routing to alternative sharing mechanisms.
Implement exponential backoff before re-prompting after a denial. The exponential backoff implementation guide provides a production-ready delay schedule based on localStorage timestamp tracking that prevents prompt fatigue across sessions.
/**
* Reads persisted denial state to gate re-prompt eligibility.
*/
export function isSharePromptThrottled(storageKey = 'share_denied_at') {
const deniedAt = localStorage.getItem(storageKey);
if (!deniedAt) return false;
const elapsed = Date.now() - Number(deniedAt);
const backoffMs = 24 * 60 * 60 * 1000; // 24-hour minimum gap
return elapsed < backoffMs;
}
export function recordShareDenial(storageKey = 'share_denied_at') {
localStorage.setItem(storageKey, String(Date.now()));
}
Fallback Chain Architecture
The fallback chain is a first-class part of the progressive enhancement architecture, not an afterthought. Each tier in the chain should be independent — if one fails, the next activates automatically.
Tier 1 — Native share sheet: navigator.share() invoked on gesture when isSecureContext and canShare(payload) pass. Handles the widest possible OS share target list including messaging apps, AirDrop, and social apps.
Tier 2 — Clipboard copy: When native share is unavailable or throws NotAllowedError, navigator.clipboard.writeText() copies the URL. Requires the same gesture context. Display a “Link copied” confirmation inline.
Tier 3 — SMS and email fallback architectures: sms:?body=… and mailto:?subject=…&body=… URIs open the device’s native messaging or email client without any special API permission. These work on every browser that supports URI scheme handlers — which is effectively universal.
Tier 4 — QR code generation: Canvas-rendered QR codes let users transfer the URL to a secondary device without relying on OS-level integrations. This also covers screen-to-screen kiosk scenarios.
Offline Share Queue
Share attempts made when the device is offline or the network is degraded should not silently fail. The offline share queue implementation stores pending payloads in IndexedDB and retries via a service worker sync event when connectivity is restored.
Syncing offline share queues with service workers details the Background Sync API registration, retry limits, and payload expiration logic needed for a production-safe implementation.
/**
* Enqueues a share payload for offline retry via Background Sync.
* Call this when navigator.share() throws or when navigator.onLine is false.
*/
export async function enqueueOfflineShare(payload) {
const db = await openShareQueueDB();
await db.add('queue', {
payload,
queuedAt: Date.now(),
attempts: 0
});
if ('serviceWorker' in navigator && 'SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('share-queue-sync');
}
}
Cross-Browser Behaviour
| Browser | Desktop navigator.share |
Mobile navigator.share |
File sharing (files) |
canShare() |
|---|---|---|---|---|
| Chrome 89+ | Yes (Windows/ChromeOS) | Yes (Android) | Yes (MIME allow-list applies) | Yes |
| Chrome macOS | No (macOS < macOS Monterey) | — | No | No |
| Safari 14+ | No (desktop Safari) | Yes (iOS 14+) | Partial (limited MIME types) | Yes (iOS 15.4+) |
| Firefox | No | No | No | No |
| Edge 93+ | Yes (Windows) | Yes (Android) | Yes | Yes |
| Samsung Internet 8.2+ | Yes | Yes | Partial | Yes |
Key platform quirks:
- iOS Safari invokes the system share sheet but filters file types aggressively. Test
navigator.canShare({ files: [yourFile] })on a real device before shipping file share features. - Android Chrome accepts
text/plain,text/html,image/png,image/jpeg,application/pdfand several others. Exotic MIME types silently fall back to fewer share targets rather than throwing. - Desktop Chrome on Windows opens a native Windows share dialog. On macOS it is blocked below Monterey and still inconsistent on newer versions — always route macOS through the clipboard fallback.
- Firefox has no
navigator.shareimplementation on any platform as of mid-2026. All Firefox users hit the fallback chain. - Calling
navigator.share()inside an iframe requires theallow="web-share"permission policy attribute on the<iframe>element; omitting it causesNotAllowedErroreven with a valid gesture.
Progressive Enhancement Decision Checklist
Apply this checklist at the component level — not globally — so each share trigger evaluates capabilities independently:
- Call
assertSecureContext()at module init. Throw iffalse; the component should not render at all on insecure origins. - Check
typeof navigator.share === 'function'. If absent, skip to step 5. - Check
navigator.canShare(payload)with the actual runtime payload. Iffalse, skip to step 5. - Bind
navigator.share(payload)to the gesture handler. CatchAbortErrorsilently (user chose not to share),NotAllowedError(fall to step 5),DataError(fix payload, retry once, then fall to step 5). - Attempt
navigator.clipboard.writeText(url). If Permissions API indicatesdenied, skip to step 6. - Render
sms:andmailto:URI links as anchor elements — no JS required, always available. - Render QR code via Canvas API as the final universal option.
/**
* Dynamically renders share controls based on runtime capabilities.
* Each control tier is rendered independently so partial support is handled gracefully.
*/
export function initShareUI(containerEl) {
if (!window.isSecureContext) return;
const nativeBtn = containerEl.querySelector('[data-action="native-share"]');
const clipboardBtn = containerEl.querySelector('[data-action="clipboard-share"]');
const uriLinks = containerEl.querySelector('[data-action="uri-fallbacks"]');
const qrContainer = containerEl.querySelector('[data-action="qr-share"]');
const { supported, canShare } = evaluateShareCapabilities();
if (supported && canShare) {
nativeBtn?.removeAttribute('hidden');
nativeBtn?.addEventListener('click', () => handleShareWithFallback(buildPayload()));
} else {
// Always show clipboard and URI fallbacks when native share is absent
clipboardBtn?.removeAttribute('hidden');
uriLinks?.removeAttribute('hidden');
qrContainer?.removeAttribute('hidden');
}
}
Error Handling Reference
| DOMException name | navigator.share() condition |
Recovery |
|---|---|---|
NotAllowedError |
Called outside user gesture; or share blocked by browser/OS policy | Fall to clipboard, then URI fallbacks. Record denial timestamp via recordShareDenial() to throttle re-prompts. |
AbortError |
User dismissed the share sheet without selecting a target | No recovery needed — this is an intentional user action. Do not show an error. |
DataError |
Payload has no valid field (title, text, or url all absent or empty); or files present but canShare() returned false |
Validate payload before calling share(). Fix the missing field. If files cause the error, strip them and retry with URL only. |
TypeError |
Not a secure context; or data argument is not a valid ShareData object |
Check window.isSecureContext first. Validate object shape before invocation. |
InvalidStateError |
Document is not in a fully active state (e.g., called from a backgrounded page) | Defer the share action until the page regains focus via the Page Visibility API. |
Telemetry and Permission State Observability
Track PermissionState transitions (prompt → granted → denied) without storing identifiable user data. Aggregate metrics at the session level.
/**
* Structured telemetry event for share outcome tracking.
* Replace the console.log with your analytics sink.
*/
export function trackShareOutcome(outcome, method) {
const event = {
event: 'share_outcome',
outcome, // 'shared' | 'cancelled' | 'clipboard_copy' | 'fallback_required'
method, // 'native' | 'clipboard' | 'sms' | 'mailto' | 'qr'
timestamp: Date.now(),
// Do not include URL, title, or any user-identifiable payload fields
};
console.log('[share-telemetry]', event);
// analytics.track(event);
}
Monitor fallback activation rates. A high rate of clipboard_copy or uri_fallback outcomes on Android Chrome indicates either that canShare() is returning false for your payload format (fix the MIME type or strip unsupported fields) or that gesture binding is broken (the handler is not executing synchronously from the click event).
Common Pitfalls
- Requesting share on page load — this always throws
NotAllowedErrorand trains users to expect browser permission blocking on your domain. - Calling
navigator.canShare()with a hypothetical payload at module init instead of the actual runtime payload — file types and MIME support vary per payload. - Assuming HTTPS guarantees
isSecureContext— sandboxed iframes withoutallow-same-originreportfalseregardless of origin. - Blocking the main thread between the gesture event and the
navigator.share()call — any asynchronous gap (evenawait someCheck()) that crosses a task boundary may consume the transient activation beforeshare()is called. - Failing to persist denial state across navigation, leading to repeated
NotAllowedErrorloops on every page visit. - Omitting
allow="web-share"on<iframe>elements — permission policy blocksnavigator.share()inside iframes by default.
FAQ
Why must permission requests be tied to user gestures?
Browsers enforce transient activation requirements to prevent abusive pop-ups and confirm explicit user intent. navigator.share() called outside a gesture context throws NotAllowedError immediately. This also impacts permission grant rates: prompts triggered by direct user action convert significantly better than those shown on page load.
How does progressive enhancement apply to the Web Share API?
Progressive enhancement means the core sharing action always works. The experience upgrades to the native OS share sheet where navigator.share is available and navigator.canShare(payload) returns true. When those conditions fail it degrades through clipboard copy, mailto:/sms: URI schemes, or a QR code — the user is never left at a dead end.
What is the impact of mixed content on secure context validation?
window.isSecureContext reflects the page’s own origin, not individual sub-resources. Mixed content (HTTP assets on an HTTPS page) does not immediately flip isSecureContext to false, but it degrades effective security and in some browsers disables specific APIs. Maintain strict asset integrity and keep all sub-resources on HTTPS.
Can I call navigator.share() and navigator.clipboard.writeText() in the same gesture handler?
Yes, but only serially. Transient activation is consumed by the first asynchronous API call that requires it. await navigator.share() first; if it throws, the catch block can then invoke clipboard.writeText() as the fallback. Calling both concurrently with Promise.all() will cause one to fail with NotAllowedError.