QR Code Generation for Cross-Device Sharing

This guide is part of Permission Flows & Progressive Enhancement.

When the Web Share API is unavailable — on a desktop browser, inside an insecure iframe, or after a system policy block — users still need a way to hand a link to another device. A dynamically generated QR code fills that gap instantly: no native permission prompts, no server round-trip, no clipboard access required. This page covers the full implementation pipeline from feature detection and routing through canvas rendering, accessible overlay construction, and offline asset caching.


Share API vs QR code routing decision flow A flowchart showing how evaluateShareCapability() checks isSecureContext, navigator.share, and navigator.canShare to route to native OS share or QR canvas fallback, with a final clipboard text safety net. User taps Share isSecureContext && navigator.share? Yes navigator .share() AbortError No fallback needed No QR canvas fallback renderQRFallback(canvas, url) canvas fails Copy-to-clipboard text link NotAllowedError

Problem Framing

Without a QR code fallback, your share flow dead-ends for a significant user segment. Desktop Chrome ships without a system-level share sheet. Firefox 126 and later added navigator.share() on desktop, but many corporate deployments are still on older versions. Safari on macOS 13+ supports the API, but only when the page is served from a secure context — any accidental HTTP load silently drops the API. And even on supported browsers, users who dismiss the native dialog (AbortError) or hit a policy block (NotAllowedError) need a path forward that does not require a page reload or a customer service call.

A client-side QR code generated from the target URL solves all of these at once: it works offline, requires no server infrastructure, and demands no permissions from the browser.


Prerequisites

Before implementing:

  • Secure context confirmed: window.isSecureContext must be true. Without HTTPS (or localhost), canvas fingerprinting restrictions and CSP rules can silently break rendering.
  • No permission required for QR generation itself — canvas and SVG rendering bypass the Permissions API entirely.
  • A QR encoding library or algorithm: the qrcode npm package (npm install qrcode) is the most widely maintained option and supports async canvas rendering. If bundle size is a constraint, the qr-creator package is 6 kB minified and gzipped with no dependencies.
  • Canvas element in the DOM (or created off-screen) before calling the renderer. Off-screen canvas via new OffscreenCanvas(256, 256) works in all modern browsers and avoids forced layout.
  • Service Worker registered if you intend to cache the generated QR asset for offline reuse.

Browser Support

Feature Chrome Firefox Safari Edge
navigator.share() 61 (mobile), 89 (desktop) 126+ 12.1+ (iOS), 13+ (macOS) 81+
navigator.canShare() 75+ 126+ 14+ 81+
OffscreenCanvas 69+ 105+ 16.4+ 79+
<canvas> 2D context All All All All
IndexedDB (QR cache) All All All All
Service Worker 45+ 44+ 11.1+ 17+

QR canvas rendering itself has no meaningful browser gap — every evergreen browser exposes the 2D canvas context. The feature detection work targets navigator.share, not the rendering pipeline.


Step-by-Step Implementation

Step 1 — Evaluate Share Capability and Route

Run all capability checks synchronously before touching any async API. This prevents a flash of incorrect UI when share support resolves later than the component renders.

// share-router.js
export function evaluateShareCapability(url) {
  if (!url || typeof url !== 'string') {
    throw new TypeError('evaluateShareCapability: url must be a non-empty string');
  }

  const isSecure = window.isSecureContext;
  const hasNativeShare = typeof navigator.share === 'function';
  const payload = { url, title: document.title };
  const canSharePayload = isSecure && hasNativeShare
    ? (navigator.canShare?.(payload) ?? true)
    : false;

  if (isSecure && hasNativeShare && canSharePayload) {
    return { route: 'native', payload };
  }

  return { route: 'qr-fallback', payload };
}

navigator.canShare() is the right guard for validating share payloads before calling navigator.share() — it rejects malformed URLs and file types the OS cannot handle before you burn a user gesture on a guaranteed failure.

Step 2 — Invoke Native Share with Full Error Boundaries

When the router returns 'native', attempt the OS share sheet. Wrap every call in specific DOMException handling — generic catch (err) branches hide the data that tells you whether to fall back silently or surface a UI message.

// native-share.js
export async function invokeNativeShare(shareData) {
  if (!window.isSecureContext) {
    throw new Error('invokeNativeShare: insecure context');
  }
  if (typeof navigator.share !== 'function') {
    throw new Error('invokeNativeShare: Web Share API unavailable');
  }

  const trigger = document.querySelector('[data-share-trigger]');
  trigger?.setAttribute('aria-busy', 'true');

  try {
    await navigator.share(shareData);
    return { status: 'success' };
  } catch (error) {
    if (error.name === 'AbortError') {
      // User dismissed the share sheet — no fallback needed
      return { status: 'user_cancelled' };
    }
    if (error.name === 'NotAllowedError') {
      // Policy or permission block — activate QR fallback
      return { status: 'policy_denied' };
    }
    if (error.name === 'DataError') {
      // Malformed payload slipped through canShare
      return { status: 'invalid_payload' };
    }
    throw error; // Unknown — rethrow for global telemetry
  } finally {
    trigger?.removeAttribute('aria-busy');
  }
}

See Handling Permission Denials Gracefully for the full taxonomy of NotAllowedError sources and the retry strategies that avoid permission fatigue when re-prompting users.

Step 3 — Render the QR Code on Canvas

Defer loading the QR library until the fallback route is confirmed. Dynamic import() keeps the initial bundle clean; the async paint keeps the main thread unblocked during error-correction matrix computation for dense URLs.

// qr-renderer.js
export async function renderQRFallback(canvasEl, targetUrl) {
  if (!(canvasEl instanceof HTMLCanvasElement)) {
    throw new TypeError('renderQRFallback: canvasEl must be an HTMLCanvasElement');
  }
  if (!targetUrl || typeof targetUrl !== 'string') {
    throw new TypeError('renderQRFallback: targetUrl must be a non-empty string');
  }

  // Lazy-load the QR library only when the fallback route is active
  const { default: QRCode } = await import('qrcode');

  try {
    await QRCode.toCanvas(canvasEl, targetUrl, {
      width: 256,
      margin: 2,
      errorCorrectionLevel: 'M',
      color: {
        dark: getComputedStyle(document.documentElement)
          .getPropertyValue('--color-qr-dark').trim() || '#1a1a1a',
        light: getComputedStyle(document.documentElement)
          .getPropertyValue('--color-qr-light').trim() || '#fafaf8'
      }
    });

    canvasEl.setAttribute('role', 'img');
    canvasEl.setAttribute('aria-label', 'Scan to share this page on another device');
    canvasEl.removeAttribute('aria-hidden');

    return { status: 'rendered' };
  } catch (error) {
    console.error('[qr-renderer] canvas render failed:', error);
    return { status: 'failed', fallbackUrl: targetUrl };
  }
}

The errorCorrectionLevel: 'M' setting (15 % redundancy) is the right default for URL payloads: it tolerates minor print or screen damage without bloating the QR matrix size enough to cause scanning problems at arm’s length on a phone.

Step 4 — Compose the Accessible Overlay

The canvas element alone is not enough — users navigating by keyboard or screen reader need to interact with the overlay without a mouse. Wire Escape to dismiss, trap focus within the modal, and expose a plain-text fallback link for environments where canvas rendering fails or is inaccessible.

// qr-overlay.js
export function mountQROverlay(canvasEl, targetUrl) {
  const overlay = document.createElement('div');
  overlay.setAttribute('role', 'dialog');
  overlay.setAttribute('aria-modal', 'true');
  overlay.setAttribute('aria-label', 'Share via QR code');
  overlay.setAttribute('tabindex', '-1');

  const closeBtn = document.createElement('button');
  closeBtn.type = 'button';
  closeBtn.textContent = 'Close';
  closeBtn.setAttribute('aria-label', 'Close QR code overlay');

  const fallbackLink = document.createElement('a');
  fallbackLink.href = targetUrl;
  fallbackLink.textContent = targetUrl;
  fallbackLink.setAttribute('aria-label', 'Direct link if QR code is unreadable');

  overlay.append(canvasEl, fallbackLink, closeBtn);
  document.body.appendChild(overlay);
  overlay.focus();

  function dismiss() {
    overlay.remove();
    document.querySelector('[data-share-trigger]')?.focus();
  }

  closeBtn.addEventListener('click', dismiss);
  overlay.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') dismiss();
  });

  return { overlay, dismiss };
}

Step 5 — Cache the QR Asset for Offline Sharing

Store the rendered canvas as a Blob in IndexedDB so repeat share attempts in offline sessions skip re-generation. This is especially useful in offline share queue implementations where a service worker intercepts share intents and serves queued assets.

// qr-cache.js
const DB_NAME = 'qr-cache';
const STORE_NAME = 'blobs';
const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours

async function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export async function cacheQRBlob(url, canvasEl) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    canvasEl.toBlob(async (blob) => {
      if (!blob) return reject(new Error('canvas.toBlob() returned null'));
      const tx = db.transaction(STORE_NAME, 'readwrite');
      tx.objectStore(STORE_NAME).put({ blob, cachedAt: Date.now() }, url);
      tx.oncomplete = () => resolve({ status: 'cached' });
      tx.onerror = () => reject(tx.error);
    }, 'image/png');
  });
}

export async function getCachedQRBlob(url) {
  const db = await openDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE_NAME, 'readonly');
    const req = tx.objectStore(STORE_NAME).get(url);
    req.onsuccess = () => {
      const record = req.result;
      if (!record) return resolve(null);
      if (Date.now() - record.cachedAt > TTL_MS) return resolve(null); // expired
      resolve(record.blob);
    };
    req.onerror = () => reject(req.error);
  });
}

Payload Validation and Error Boundaries

A complete share flow integrates all of the above into a single entry point that routes between native and QR paths and exposes the text-link safety net as a final fallback.

// share-entry.js
import { evaluateShareCapability } from './share-router.js';
import { invokeNativeShare } from './native-share.js';
import { renderQRFallback } from './qr-renderer.js';
import { mountQROverlay } from './qr-overlay.js';
import { cacheQRBlob, getCachedQRBlob } from './qr-cache.js';

export async function initiateShare(triggerEl) {
  const url = triggerEl?.dataset?.shareUrl ?? window.location.href;
  const { route, payload } = evaluateShareCapability(url);

  if (route === 'native') {
    const result = await invokeNativeShare(payload);
    if (result.status === 'user_cancelled') return; // silent — user dismissed
    if (result.status !== 'success') {
      // policy_denied or invalid_payload — fall through to QR
    } else {
      return;
    }
  }

  // QR path
  const canvasEl = document.createElement('canvas');
  const cached = await getCachedQRBlob(url);

  if (cached) {
    const img = new Image();
    img.src = URL.createObjectURL(cached);
    img.setAttribute('role', 'img');
    img.setAttribute('alt', 'Scan to share this page on another device');
    mountQROverlay(img, url);
    return;
  }

  const result = await renderQRFallback(canvasEl, url);
  if (result.status === 'rendered') {
    await cacheQRBlob(url, canvasEl);
    mountQROverlay(canvasEl, url);
  } else {
    // Canvas failed — expose the text link directly
    const link = document.createElement('a');
    link.href = url;
    link.textContent = url;
    document.body.appendChild(link);
    link.focus();
  }
}

Platform Gotchas

iOS Safari (all versions): navigator.share() is only callable from a direct user gesture handler — click, touchend, or pointerup. Calling it from a setTimeout or a resolved Promise that is more than one microtask tick removed from the user gesture throws NotAllowedError. Keep the await navigator.share() call as close to the event handler as possible.

Android Chrome (Custom Tabs): Custom Tab contexts inherit the host app’s package-level share configuration. Some banking and news apps suppress the native share sheet entirely, causing NotAllowedError with no user-visible dialog. Route immediately to QR in these cases rather than retrying.

Desktop Chrome/Edge: Both browsers added navigator.share() in late 2020/early 2021, but the OS share sheet on Windows may surface only a handful of targets (Mail, OneNote, Skype). Users who expect broader targets often prefer a QR code or a clipboard copy over the OS dialog. Detecting the desktop user-agent and presenting both options simultaneously is a reasonable UX pattern.

Firefox 125 and earlier (all platforms): navigator.share is undefined. Always initialize the QR canvas element in the DOM before the user gesture to avoid a visible layout shift when the fallback renders.

Canvas memory on low-end Android: 256 × 256 px is safe for all modern devices. Do not increase beyond 512 × 512 px — some low-memory WebViews hit canvas size limits and throw synchronously during getContext('2d').

Insecure iframes: Even if the top-level page is HTTPS, a cross-origin iframe served without the allow="web-share" permissions policy attribute sees navigator.share as undefined. Detect the iframe context with window.self !== window.top and go straight to QR without attempting native share.


Testing and Verification

DevTools simulation: In Chrome DevTools, open Application → Service Workers and check “Offline” to test the cached QR blob path. Throttle the CPU under the Performance panel to expose main-thread jank during synchronous QR encoding.

Physical device checklist:

  • Scan the generated QR code with the camera app on a second phone (iOS Camera, Android Google Lens) before shipping.
  • Test at arm’s length (~40 cm) under typical indoor lighting — error correction level M handles minor glare but not full washout.
  • On iOS, verify that tapping the share button from a WKWebView embedded in a native app still triggers the overlay (WKWebView has navigator.share as undefined in all iOS versions up to iOS 17).

Accessibility audit:

  • Run axe against the overlay with npx axe-cli http://localhost:8080/your-page and confirm zero violations on the role="dialog" element.
  • Test keyboard focus trap: tab cycling must stay within the overlay, and Escape must return focus to the share trigger.
  • Verify aria-label on the canvas element is announced by VoiceOver (macOS) and TalkBack (Android) before the overlay is opened.

Unit test routing logic:

import { evaluateShareCapability } from './share-router.js';

describe('evaluateShareCapability', () => {
  it('routes to qr-fallback when navigator.share is absent', () => {
    Object.defineProperty(window, 'isSecureContext', { value: true });
    delete navigator.share;
    const result = evaluateShareCapability('https://example.com/page');
    expect(result.route).toBe('qr-fallback');
  });

  it('routes to native when all conditions pass', () => {
    Object.defineProperty(window, 'isSecureContext', { value: true });
    navigator.share = async () => {};
    navigator.canShare = () => true;
    const result = evaluateShareCapability('https://example.com/page');
    expect(result.route).toBe('native');
  });
});

FAQ

When should I trigger the QR fallback instead of navigator.share()?

Evaluate navigator.share support, window.isSecureContext, and navigator.canShare() synchronously before attempting native share. If any check fails, or if navigator.share() throws AbortError or NotAllowedError, activate the QR generation pipeline.

Does QR code generation require browser permissions?

No. Client-side QR generation relies on canvas or SVG rendering and triggers no permission prompts. If you offer a download of the generated image, bind it to a direct user gesture to avoid popup blockers.

How should I handle NotAllowedError in the share flow?

Catch it explicitly, record it for analytics, and transition the UI to the QR fallback without immediately retrying the native API. Apply exponential backoff for permission re-prompts before any subsequent retry attempt.

Can I cache generated QR codes for offline sharing?

Yes. Store the rendered canvas as a Blob in IndexedDB or the Cache API. Pair it with a Service Worker to serve the cached QR asset when the network is unavailable. Set a TTL of 24 hours to prevent serving stale codes for time-sensitive URLs.