Choosing Between Native Share and a Custom Sheet

This guide is part of Building a Custom Share Sheet, which itself is part of Permission Flows and Progressive Enhancement.

The two routes are not competitors, and treating them as a style choice is how share flows go wrong. The native sheet is a system service: it knows which apps are installed, remembers recent recipients, and is the only path that can hand a file to another application. A custom dialog is a list you maintain, reaching the destinations you predicted. Where the native sheet exists it should almost always win; where it does not, a custom sheet is the difference between a working feature and a dead button.

Feature Detection Gate

One function, resolved during render, returns the whole decision.

// share-route.js
export function resolveShareRoute(payload, env = globalThis) {
  if (env.isSecureContext !== true) return { route: 'custom', reason: 'insecure-context' };
  if (typeof env.navigator?.share !== 'function') return { route: 'custom', reason: 'no-api' };

  if (payload?.files?.length) {
    if (typeof env.navigator.canShare !== 'function') return { route: 'custom', reason: 'no-canshare' };
    if (!env.navigator.canShare(payload)) return { route: 'custom', reason: 'files-rejected' };
  }
  return { route: 'native' };
}

Because the whole decision is synchronous and cheap, it can run on every render — which is what allows the label to be correct before anyone touches it.

What Each Route Can and Cannot Do Six capabilities compared. The native sheet can reach every installed app, carry files, remember recent recipients and needs no maintenance, but it is unavailable on much of the desktop and cannot be styled. The custom dialog is always available and fully styleable, but reaches only the destinations you hard-code and can never hand over a file. Capability Native sheet Custom dialog reaches every installed app yes only what you list hands over files yes never remembers recent recipients yes no available on desktop partly always matches your design system no fully maintenance cost none ongoing

Step 1 — Let the Route Choose the Control

The label, the icon and the handler all come from the same resolution, so they cannot disagree.

// share-control.js
import { resolveShareRoute } from './share-route.js';

export function shareControl(payload) {
  const { route, reason } = resolveShareRoute(payload);

  if (route === 'native') {
    return { label: 'Share', icon: 'share', action: 'native' };
  }
  if (reason === 'files-rejected' || reason === 'no-canshare') {
    // Sharing works here — just not with these attachments.
    return { label: 'Share link', icon: 'share', action: 'custom', note: 'Attachments cannot be shared here' };
  }
  return { label: 'Share', icon: 'share', action: 'custom' };
}

The middle case is the one worth getting right. When files are the only thing rejected, the honest control is not “Copy link” — sharing genuinely works, just without the attachment — and telling the user that up front avoids the impression that the feature is broken.

Step 2 — Re-Validate Inside the Handler

Render-time resolution decides the label; act-time resolution decides the behaviour. They can differ, because the payload may have changed since the last render.

// perform-share.js
import { resolveShareRoute } from './share-route.js';

export async function performShare(payload, sheet, triggerEl) {
  const { route } = resolveShareRoute(payload);      // re-check: the payload may have changed

  if (route === 'custom') {
    sheet.open(payload, triggerEl);
    return { outcome: 'custom-sheet' };
  }

  try {
    await navigator.share(payload);                  // synchronous inside the gesture
    return { outcome: 'shared' };
  } catch (err) {
    if (err.name === 'AbortError') return { outcome: 'dismissed' };
    sheet.open(payload, triggerEl);                  // native failed — the custom sheet still works
    return { outcome: 'custom-sheet', reason: err.name };
  }
}

The catch branch is what makes the pair complementary rather than exclusive: a native attempt that fails for any reason other than dismissal falls through to the sheet you already built, so the user is never left with nothing. Note that a dismissal must not open the custom sheet — the user just declined to share, and reopening a second chooser reads as the app arguing with them.

Step 3 — Pick One Route per Environment

Rendering both a native Share button and a “More options” custom sheet duplicates one intent and asks the user to guess. The exception is copy-link, which is worth keeping visible everywhere because it needs no app, no account and no permission.

// sheet-contents.js
export function sheetItems({ route, payload }) {
  const items = [{ key: 'copy', label: 'Copy link', always: true }];

  if (route === 'custom') {
    items.push(
      { key: 'email', label: 'Email' },
      { key: 'whatsapp', label: 'WhatsApp' },
      { key: 'linkedin', label: 'LinkedIn' }
    );
  }
  if (payload?.files?.length) {
    items.push({ key: 'download', label: 'Download file' });
  }
  return items;
}

Keeping the list short matters more than covering every network. Four destinations that suit your audience get used; twelve become a wall of icons that nobody scans, and each one is a maintenance liability when the destination changes its endpoint.

From Environment to Rendered Control The environment and payload feed a single resolution step. A supported environment with a valid payload renders a Share button using the native sheet. Files rejected but sharing supported renders Share link with a note. No API or an insecure context renders Share backed by the custom dialog. resolveShareRoute runs during render route: native API present, payload validates route: custom (files rejected) sharing works, attachments do not route: custom (no API) desktop, or insecure context "Share" → native sheet files included "Share link" + note honest about the limit "Share" → custom dialog copy-link first Decide at Render, Confirm at Act The route is resolved during render so the label is truthful before any tap, then resolved again inside the handler because the payload may have changed. Deciding only after the tap is what produces a Share button that quietly copies a link. render resolve route → set the label user taps expectation already set re-resolve, then act payload may have changed deciding only after the tap the label promised a share sheet and the app delivered a clipboard write — the complaint writes itself

Failure Modes and Recovery

Symptom Cause Minimal fix
Button says Share, then copies a link Route resolved after the tap Resolve during render and label from it
Native and custom both offered Two controls for one intent Pick one per environment; keep copy-link as the extra
Files silently dropped canShare result ignored Route to the custom sheet and say attachments are excluded
Dismissal reopens a chooser AbortError treated as a failure Return on AbortError; do not open the sheet
Desktop users get no share at all Custom route never built Custom sheet is the desktop path, not an edge case
Photo cannot reach the user’s messaging app Custom sheet used everywhere by choice Prefer native whenever it validates

Browser and Platform Caveat

The split follows the platform rather than the browser brand: mobile Chrome and Safari have the native sheet, desktop Safari and Windows Chrome have a narrower version of it, and Firefox and Linux Chrome typically have none — so on desktop the custom dialog is the main path rather than a fallback. Two consequences follow. First, the custom sheet deserves the same design and accessibility attention as the native flow, because for a large share of visitors it is the flow. Second, the native path cannot be verified by desktop testing at all; confirm it on hardware using the approach in debugging navigator.share with remote DevTools, and keep a test that runs with the API deleted so the custom route stays continuously covered.

FAQ

Should I show both a native share button and a custom sheet?

Showing both duplicates the same intent and forces the user to guess which one works. Pick one route per environment. The exception is a secondary copy-link control, which is useful alongside either route because it needs no app and no permission.

Is it acceptable to always use a custom sheet for design consistency?

It costs real capability. The native sheet lists the apps the user actually has, remembers their recent targets, and can hand over files — none of which a web dialog can do. Consistent visuals are rarely worth removing the ability to share a photo to the app someone actually uses.

What should the button say when both routes exist?

Share, in both cases, as long as tapping it genuinely opens a share affordance. The label only needs to change when the action changes — a control that will copy a link should say Copy link rather than announcing Share and quietly doing something else.