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 contextwindow.isSecureContext === true (HTTPS or localhost)
  • API presencetypeof navigator.share === 'function'
  • Gesture scope — the eventual navigator.share() call is inside a direct click or pointerdown handler

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

Web Share API Permission Prompt Lifecycle State diagram showing the path from user gesture through secure-context check, payload validation, the native OS share dialog, and the three possible outcomes: share success, user abort, or system denial triggering a fallback. User Gesture Secure context + feature detection Not supported Show fallback UI Supported canShare() payload validation DataError Log + route to fallback Valid Native OS Share Dialog (browser-controlled, async) AbortError NotAllowedError Restore baseline UI silently Share success Log telemetry Trigger fallback + log telemetry

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 / setInterval callbacks
  • Promises resolved after the gesture event has propagated
  • IntersectionObserver or 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() returns false for text/html files; prefer plain text or PDF.
  • The share sheet on iOS opens immediately on button tap with no intermediate loading state — aria-busy will not be visible long enough to matter on fast devices.
  • HTTPS is mandatory. localhost is 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: [...] }) returns false and navigator.share({ files: [...] }) throws DataError on 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 #isSharing guard 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 return false on 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)

  1. Open DevTools → Application → Manifest.
  2. Add a custom share handler via the “Add to home screen” flow in a PWA if you need to test the share target side.
  3. 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 resolved Promise chain breaks the gesture linkage in Safari. Keep the await 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. AbortError is 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 — calling navigator.share({ files: [...] }) without a canShare() pre-check will throw DataError silently on unsupported MIME types.
  • Assembling File arrays at component initialisation — constructing large File objects 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. Use sessionStorage for 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.