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.
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.
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.