Designing Contextual Permission Prompts
This guide is part of Permission Flows & Progressive Enhancement.
Contextual permission prompts convert at a higher rate because they arrive when the user already intends to act, not as a cold interruption. Without deliberate trigger timing and state management, native sharing dialogs appear at wrong moments, accumulate unhandled rejections, and leave broken UI behind when users cancel. This guide walks through every layer of the pattern: secure context gating, gesture binding, payload validation, error classification, and deterministic fallback routing.
Prerequisites
Before any share UI is rendered, confirm all three conditions are true:
- Secure context —
window.isSecureContext === true(HTTPS orlocalhost) - API presence —
typeof navigator.share === 'function' - Gesture scope — the eventual
navigator.share()call is inside a directclickorpointerdownhandler
If the first two checks fail, skip share UI entirely and show the fallback UI from the start. Never render a share button that will always fail.
Browser Support Snapshot
| Browser | Minimum version | File sharing | Known gaps |
|---|---|---|---|
| Chrome Android | 61 | Yes (Chrome 89+) | Requires user gesture; canShare since 75 |
| Safari iOS | 12.2 | Yes (iOS 14+) | Restricted MIME allowlist; no share() on desktop until Safari 15 |
| Safari macOS | 15 | Yes | Desktop share sheet differs from mobile; fewer target apps |
| Chrome desktop | 89 | No | Text/URL only; file sharing blocked on desktop Chrome |
| Firefox | Not supported | No | Ships behind a flag only; do not detect as supported |
| Edge (Chromium) | 81 | Mirrors Chrome | Same gesture and canShare behaviour as Chrome |
| Samsung Internet | 8.2 | Yes | Follows Chrome Android parity |
Permission Prompt Lifecycle Diagram
Step-by-Step Implementation
Step 1 — Gate on secure context and API availability
Render share UI only when both checks pass. Conditionally hiding the button prevents DOM bloat and eliminates dead click handlers on Firefox and HTTP pages.
export function isShareSupported() {
return (
typeof navigator !== 'undefined' &&
typeof navigator.share === 'function' &&
window.isSecureContext === true
);
}
export function initShareButton(buttonEl, fallbackEl) {
if (!isShareSupported()) {
buttonEl.hidden = true;
fallbackEl.hidden = false;
return;
}
buttonEl.hidden = false;
fallbackEl.hidden = true;
}
Defer all further initialisation until this gate passes. If it fails, route immediately to a copy-to-clipboard fallback or an SMS/email fallback URI.
Step 2 — Bind the share call to a direct user gesture
Native sharing must be triggered inside a click or pointerdown handler. Browsers block any navigator.share() call that occurs outside an active user interaction, throwing NotAllowedError.
Do not call navigator.share() from:
setTimeout/setIntervalcallbacks- Promises resolved after the gesture event has propagated
IntersectionObserveror scroll callbacks- Page load or
DOMContentLoaded
export function bindShareTrigger(buttonEl, getPayload) {
buttonEl.addEventListener('click', async (event) => {
event.preventDefault();
const payload = getPayload(); // assembled at gesture time, not at init
await invokeShare(payload, buttonEl);
});
}
Step 3 — Validate the payload with navigator.canShare() before invoking
navigator.canShare() performs a synchronous pre-flight against the browser’s supported MIME types and field constraints. Call it before navigator.share() to catch DataError without opening a native dialog.
export function sanitizePayload({ title, text, url, files }) {
const result = {};
if (title) result.title = String(title).trim();
if (text) result.text = String(text).trim();
if (url) {
try {
result.url = new URL(url).toString();
} catch {
throw new DOMException('Invalid URL in share payload.', 'DataError');
}
}
if (files && Array.isArray(files)) result.files = files;
return result;
}
export function isPayloadShareable(payload) {
if (typeof navigator.canShare !== 'function') return true; // API absent, let share() decide
return navigator.canShare(payload);
}
For a deeper walkthrough of navigator.canShare() including MIME allowlist behaviour, see implementing navigator.canShare() for graceful fallbacks.
Step 4 — Invoke share with loading state and aria-busy
Set aria-busy="true" on the trigger immediately so assistive technology communicates the pending state. The finally block guarantees cleanup regardless of outcome.
export async function invokeShare(rawPayload, buttonEl) {
if (buttonEl.dataset.sharing === 'true') return; // guard against double-tap
buttonEl.dataset.sharing = 'true';
buttonEl.setAttribute('aria-busy', 'true');
try {
const payload = sanitizePayload(rawPayload);
if (!isPayloadShareable(payload)) {
handleFallback(payload);
return;
}
await navigator.share(payload);
logShareTelemetry('share_success');
} catch (err) {
handleShareError(err, rawPayload);
} finally {
delete buttonEl.dataset.sharing;
buttonEl.removeAttribute('aria-busy');
buttonEl.focus();
}
}
Step 5 — Classify errors and route accordingly
The Web Share API produces three meaningful error types. Treating all of them the same way produces either excessive fallback triggers or missed recovery opportunities.
export function handleShareError(err, payload) {
switch (err.name) {
case 'AbortError':
// User dismissed the dialog — no fallback needed, no log required
return;
case 'NotAllowedError':
// Browser or OS blocked the share (gesture violation or permissions policy)
handleFallback(payload);
logShareTelemetry('share_denied');
break;
case 'DataError':
case 'TypeError':
// Payload rejected — bad MIME type or malformed field
console.warn('[Share] Payload rejected:', err.message);
handleFallback(payload);
logShareTelemetry('share_data_error');
break;
default:
console.error('[Share] Unexpected error:', err);
handleFallback(payload);
}
}
Step 6 — Implement the fallback dispatcher
When native sharing fails or is unavailable, preserve user intent by routing to an appropriate alternative. For prompts that have been denied multiple times, see handling permission denials gracefully for exponential backoff and degradation strategies.
export function handleFallback(payload) {
// Clipboard fallback for text and URL payloads
const clipboardText = payload.url || payload.text || '';
if (clipboardText && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(clipboardText).catch(() => {});
}
// Dispatch a custom event so host components can surface a fallback UI
document.dispatchEvent(
new CustomEvent('share:fallback', { detail: payload, bubbles: true })
);
}
function logShareTelemetry(event) {
if ('sendBeacon' in navigator) {
navigator.sendBeacon('/api/telemetry/share', JSON.stringify({ event }));
}
}
Complete Class-Based Implementation
The steps above compose into a production-ready module. This version adds double-invocation protection and session-level dismissal tracking using sessionStorage — which resets on new sessions, unlike localStorage.
/**
* ContextualShareManager
* Web Share API orchestration with feature gating, payload validation,
* error classification, and deterministic fallback routing.
*/
export class ContextualShareManager {
#isSupported;
#isSharing = false;
#dismissalKey = 'share_dismissals';
constructor() {
this.#isSupported =
typeof navigator !== 'undefined' &&
typeof navigator.share === 'function' &&
window.isSecureContext === true;
}
/**
* Binds share logic to a trigger element.
* @param {HTMLElement} triggerEl
* @param {() => { title?: string, text?: string, url?: string, files?: File[] }} getPayload
*/
bindTrigger(triggerEl, getPayload) {
if (!this.#isSupported) {
triggerEl.addEventListener('click', () => this.#executeFallback(getPayload()));
return;
}
triggerEl.addEventListener('click', async (event) => {
event.preventDefault();
await this.#invokeNativeShare(getPayload(), triggerEl);
});
}
async #invokeNativeShare(rawPayload, triggerEl) {
if (this.#isSharing) return;
this.#isSharing = true;
triggerEl.setAttribute('aria-busy', 'true');
try {
const payload = this.#sanitizePayload(rawPayload);
if (typeof navigator.canShare === 'function' && !navigator.canShare(payload)) {
this.#executeFallback(rawPayload);
return;
}
await navigator.share(payload);
this.#logTelemetry('share_success');
} catch (err) {
this.#handleShareError(err, rawPayload);
} finally {
this.#isSharing = false;
triggerEl.removeAttribute('aria-busy');
triggerEl.focus();
}
}
#sanitizePayload({ title, text, url, files }) {
const out = {};
if (title) out.title = String(title).trim();
if (text) out.text = String(text).trim();
if (url) {
try {
out.url = new URL(url).toString();
} catch {
throw new DOMException('Invalid URL in share payload.', 'DataError');
}
}
if (Array.isArray(files)) out.files = files;
return out;
}
#handleShareError(err, payload) {
switch (err.name) {
case 'AbortError':
this.#incrementDismissalCount();
return; // expected user behaviour — no fallback
case 'NotAllowedError':
this.#executeFallback(payload);
this.#logTelemetry('share_denied');
break;
case 'DataError':
case 'TypeError':
console.warn('[Share] Payload rejected:', err.message);
this.#executeFallback(payload);
this.#logTelemetry('share_data_error');
break;
default:
console.error('[Share] Unexpected failure:', err);
this.#executeFallback(payload);
}
}
#executeFallback(payload) {
const text = payload.url || payload.text || '';
if (text && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).catch(() => {});
}
document.dispatchEvent(
new CustomEvent('share:fallback', { detail: payload, bubbles: true })
);
}
#incrementDismissalCount() {
const current = Number(sessionStorage.getItem(this.#dismissalKey) || 0);
sessionStorage.setItem(this.#dismissalKey, String(current + 1));
}
getDismissalCount() {
return Number(sessionStorage.getItem(this.#dismissalKey) || 0);
}
#logTelemetry(event) {
if ('sendBeacon' in navigator) {
navigator.sendBeacon('/api/telemetry/share', JSON.stringify({ event }));
}
}
}
Payload Validation and Error Boundary
navigator.canShare() is a synchronous guard that browsers evaluate against their internal MIME type allowlist without opening a native dialog. Always call it when sharing files. For text-only payloads on older Chrome or Safari, the method may be absent — the typeof check prevents a TypeError.
| Rejection name | Cause | Recovery |
|---|---|---|
AbortError |
User dismissed the native dialog | Restore UI; track dismissal count in sessionStorage |
NotAllowedError |
Call was outside a user gesture, or a permissions policy blocked it | Route to fallback; log telemetry |
DataError |
Payload contains unsupported MIME types or malformed fields | Call canShare() first to catch this before share() |
TypeError |
ShareData is empty, null, or structurally invalid |
Validate fields before calling share() |
Platform Gotchas
iOS Safari
- File sharing requires iOS 14 or later.
- The MIME allowlist is stricter than Chrome Android.
canShare()returnsfalsefortext/htmlfiles; prefer plain text or PDF. - The share sheet on iOS opens immediately on button tap with no intermediate loading state —
aria-busywill not be visible long enough to matter on fast devices. - HTTPS is mandatory.
localhostis not a secure context on a physical iOS device, so test with a self-signed certificate via an mDNS hostname.
Chrome Android
- Desktop Chrome does not support file sharing.
navigator.canShare({ files: [...] })returnsfalseandnavigator.share({ files: [...] })throwsDataErroron all desktop Chrome builds. - Chrome enforces a single active share session. If
navigator.share()is called while another share dialog is already open, the call is silently dropped. The#isSharingguard above prevents this.
Desktop Safari (macOS 15+)
- The macOS share sheet has fewer installed share destinations than mobile, so users may see only Mail, AirDrop, and Notes. This is expected OS behaviour.
- Text and URL payloads work reliably. File sharing was added alongside macOS Ventura but has the same MIME restrictions as iOS.
Firefox
- The Web Share API ships behind a flag in Firefox and is not available in production builds. Detecting
typeof navigator.share === 'function'will returnfalseon all shipping Firefox versions. Never assume a user on desktop Firefox can share natively. Route them to the QR code fallback or an SMS/email link.
Testing and Verification
Chrome DevTools (desktop)
- Open DevTools → Application → Manifest.
- Add a custom share handler via the “Add to home screen” flow in a PWA if you need to test the share target side.
- For gesture testing:
navigator.share()can be called from the DevTools console only when the page’s top-level frame has recently had user interaction (click into the page first).
Physical device checklist
Dismissal tracking verification
Open DevTools → Application → Session Storage and confirm share_dismissals increments each time the native dialog is closed without sharing. The key must not appear in Local Storage.
For timing strategies that align prompts with natural user workflows and reduce repeated dismissals, see how to avoid permission fatigue in progressive web apps.
Common Pitfalls
- Gesture context violations — calling
navigator.share()inside a resolvedPromisechain breaks the gesture linkage in Safari. Keep theawait navigator.share()call in the same synchronous execution as the event handler. - Misclassifying
AbortError— treating user cancellation as a fatal error and triggering a fallback or an error toast is a UX anti-pattern.AbortErroris the expected, healthy path when a user changes their mind. - Rendering share UI without feature detection — hardcoding a share button on all browsers leaves a non-functional element visible on Firefox and HTTP pages.
- Ignoring
canShare()for files — callingnavigator.share({ files: [...] })without acanShare()pre-check will throwDataErrorsilently on unsupported MIME types. - Assembling
Filearrays at component initialisation — constructing largeFileobjects before the user has clicked adds unnecessary memory pressure. Defer payload assembly to inside the click handler. - Storing payloads in
localStorage— share payloads may contain sensitive titles or URLs. UsesessionStoragefor transient state and never persist raw payloads across sessions.
FAQ
How do I tell the difference between user cancellation and a system permission denial?
navigator.share() rejects with AbortError when the user closes the native dialog without sharing. It rejects with NotAllowedError when the browser or OS blocks the call because of a permissions policy or gesture violation. Only NotAllowedError requires a fallback; AbortError is expected behaviour that should restore the baseline UI silently.
Can I call navigator.share() automatically on page load?
No. Every browser that implements the Web Share API requires a direct user gesture such as a click or pointerdown event. Calls outside a gesture handler are blocked with NotAllowedError or a silent rejection regardless of HTTPS status.
How should I handle file sharing on iOS Safari?
iOS restricts file sharing to a specific allowlist of MIME types and requires HTTPS. Always call navigator.canShare({ files: [...] }) before invoking share. If canShare returns false, fall back to a URI-based or copy-to-clipboard path rather than surfacing a broken native dialog.
Is it safe to assemble the ShareData payload at component initialisation time?
For lightweight text payloads it is technically safe, but deferring payload assembly until the click handler fires is strongly preferred. File arrays in particular should never be built at init time; constructing large File objects before they are needed causes unnecessary memory pressure on the main thread.
Related
- Permission Flows & Progressive Enhancement — parent section
- How to avoid permission fatigue in progressive web apps — timing strategies for fewer dismissals
- Handling Permission Denials Gracefully — degradation and exponential backoff
- Implementing exponential backoff for permission re-prompts
- SMS and Email Fallback Architectures — deterministic fallback paths
- QR code generation for cross-device sharing — visual fallback for desktop users