Building a Custom Share Sheet
This guide is part of Permission Flows and Progressive Enhancement.
On a phone, navigator.share() opens a share sheet that knows which apps the user has installed. On most desktops that API does not exist at all, and the button either does nothing or copies a link without explanation. A custom share sheet fills that gap: a small dialog listing the destinations that make sense for your content, with copy-link as the always-available default.
The mistake to avoid is treating it as a replacement. The native sheet is better wherever it exists — it can hand over files, it reflects the user’s real app list, and it costs you no maintenance. A custom sheet is what you show when there is nothing to hand off to.
What Breaks Without This Pattern
The button that does nothing. Code calls navigator.share() unconditionally, the property is undefined on desktop, a TypeError kills the handler, and the user gets no feedback at all. This is the single most common Web Share bug in production.
The button that changes its mind. The label says Share; the tap copies a link. Nothing was wrong technically, but the control lied about what it would do, which is the version users complain about.
The modal nobody can escape. A div styled as a dialog does not trap focus, does not close on Escape, and leaves the page behind it reachable by keyboard. Tab moves behind the overlay, focus is lost, and screen-reader users have no way to know the dialog opened.
The third-party script tax. Vendor share widgets pull in scripts that block rendering and track your readers, to produce links you could have written yourself in six lines.
Prerequisites
- A canonical URL for the content, resolved before the dialog opens.
- A secure context if you intend to use the async clipboard API for the copy action.
<dialog>support, which is current across Chrome, Safari, Firefox and Edge — plus a non-modal fallback for very old engines.- A decision about destinations: three or four that suit your audience beat twelve nobody uses.
Browser Support Snapshot
| Capability | Chrome | Safari | Firefox | Notes |
|---|---|---|---|---|
navigator.share |
Android, Windows, ChromeOS | iOS, macOS | Not implemented | Custom sheet is the desktop path |
<dialog> + showModal |
Yes | Yes | Yes | Focus trap and Escape come free |
navigator.clipboard.writeText |
Yes | Yes | Yes | Secure context required |
prefers-reduced-motion |
Yes | Yes | Yes | Gate all entrance animation |
Intent URLs (https:// links) |
Yes | Yes | Yes | No SDK needed anywhere |
Step 1 — Decide Before First Paint
The share affordance should be chosen while rendering, not after the click, so the label always matches the behaviour.
// share-mode.js
export function shareMode(payload) {
if (window.isSecureContext !== true) return 'custom';
if (typeof navigator.share !== 'function') return 'custom';
if (payload?.files?.length) {
if (typeof navigator.canShare !== 'function') return 'custom';
return navigator.canShare(payload) ? 'native' : 'custom';
}
return 'native';
}
One function, called during render and again immediately before acting. It is synchronous and cheap, so there is no reason to cache a result that may be wrong for the next payload.
Step 2 — Build on the Dialog Element
showModal() supplies focus containment, Escape handling, the top layer, and inertness for the rest of the document. Every one of those is a bug waiting to happen in a hand-rolled div.
<dialog id="share-sheet" class="share-sheet" aria-labelledby="share-sheet-title">
<h2 id="share-sheet-title">Share this page</h2>
<ul class="share-targets">
<li><button type="button" data-action="copy">Copy link</button></li>
<li><a data-target="email" href="#" rel="noopener">Email</a></li>
<li><a data-target="whatsapp" href="#" target="_blank" rel="noopener">WhatsApp</a></li>
<li><a data-target="linkedin" href="#" target="_blank" rel="noopener">LinkedIn</a></li>
</ul>
<button type="button" data-action="close">Close</button>
</dialog>
// share-sheet.js
import { buildTargets } from './intent-urls.js';
export function createShareSheet(dialog, { onCopy }) {
let opener = null;
dialog.addEventListener('close', () => opener?.focus()); // return focus where it came from
dialog.addEventListener('click', async (event) => {
const action = event.target.closest('[data-action]')?.dataset.action;
if (action === 'close') dialog.close();
if (action === 'copy') {
await onCopy();
dialog.close();
}
});
return {
open(payload, triggerEl) {
opener = triggerEl;
for (const anchor of dialog.querySelectorAll('[data-target]')) {
anchor.href = buildTargets(payload)[anchor.dataset.target];
}
dialog.showModal();
dialog.querySelector('[data-action="copy"]')?.focus();
}
};
}
Restoring focus to the element that opened the dialog is the detail keyboard users notice most. Without it, closing the sheet drops focus to the document body and the next Tab starts from the top of the page.
Step 3 — Generate Intent URLs Yourself
Every mainstream destination accepts a plain HTTPS URL with query parameters. No SDK, no script, no tracking.
// intent-urls.js
export function buildTargets({ title = '', text = '', url }) {
const u = encodeURIComponent(url);
const t = encodeURIComponent(title);
const body = encodeURIComponent([text, url].filter(Boolean).join('\n\n'));
return {
email: `mailto:?subject=${t}&body=${body}`,
sms: `sms:?&body=${body}`,
whatsapp: `https://wa.me/?text=${encodeURIComponent(`${title} ${url}`.trim())}`,
telegram: `https://t.me/share/url?url=${u}&text=${t}`,
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${u}`,
reddit: `https://www.reddit.com/submit?url=${u}&title=${t}`
};
}
encodeURIComponent on every interpolated value is non-negotiable: an unescaped & or # in a title truncates the payload, and an unescaped URL breaks the destination’s parsing entirely. The mailto: and sms: schemes have their own quirks, covered in SMS and email fallback architectures.
Step 4 — Make Copy Link the Default Action
Copy is the one action with no dependency on an installed app, an account, or a third party. It belongs first in the list and focused on open.
// copy-link.js
export async function copyLink(url, statusEl) {
try {
if (navigator.clipboard?.writeText && window.isSecureContext) {
await navigator.clipboard.writeText(url);
} else {
legacyCopy(url);
}
statusEl.textContent = 'Link copied';
return true;
} catch {
statusEl.textContent = 'Press Ctrl+C to copy';
return false;
}
}
function legacyCopy(value) {
const field = Object.assign(document.createElement('textarea'), {
value, readOnly: true, style: 'position:fixed;opacity:0'
});
document.body.append(field);
field.select();
document.execCommand('copy');
field.remove();
}
The status element should be a live region (role="status"), so the confirmation is announced rather than only seen — otherwise the action appears to do nothing for screen-reader users.
Step 5 — Animate Only When Motion Is Welcome
An entrance animation is fine; an unavoidable one is not. Gate it in CSS so the preference is honoured without JavaScript.
.share-sheet {
opacity: 0;
transform: translateY(8px);
transition: opacity 160ms ease, transform 160ms ease;
}
.share-sheet[open] { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
.share-sheet { transition: none; transform: none; }
}
Keep the duration short. A share sheet is a response to a deliberate tap, and anything above roughly 200 ms reads as lag rather than polish.
Step 6 — Choose the Destination List Deliberately
The destinations in the sheet are a product decision, not a technical one, and the usual failure is generosity: twelve icons that cover every network and get used by nobody. A short list beats a complete one, because a wall of logos is scanned rather than read, and every entry is a maintenance liability when the destination changes its endpoint.
Three questions narrow it quickly. Where does your audience actually talk? A developer tool and a recipe site have almost no overlap in destinations, and analytics on outbound clicks answers this within a week of shipping. Does the destination render your content well? A network that strips the preview card or truncates the title is worse than a copied link. Is the link genuinely reachable? A desktop user with no mail client configured gets nothing from a mailto: entry, so it belongs below copy-link rather than above it.
// destinations.js
export const DESTINATIONS = [
{ key: 'copy', label: 'Copy link', always: true }, // never remove this one
{ key: 'email', label: 'Email' },
{ key: 'whatsapp', label: 'WhatsApp', mobileFirst: true },
{ key: 'linkedin', label: 'LinkedIn' }
];
export function visibleDestinations({ isMobile }) {
return DESTINATIONS.filter((d) => d.always || !d.mobileFirst || isMobile);
}
Ordering carries meaning too. The first item receives focus when the dialog opens and is the one a hurried user taps, so it should be the action that always works rather than the one you would most like them to choose. Copy-link earns that position on every platform: no app, no account, no permission, no third party — the same reasoning that makes it the floor beneath both routes in the diagram above.
One further habit is worth adopting: keep the destination list in a single module, as above, rather than scattered through templates. These endpoints are product surfaces owned by other companies, not standards, and they change without notice. When one breaks, a one-line edit in a shared list is the difference between a five-minute fix and an afternoon of grep.
Platform Gotchas
mailto: and sms: can dead-end. A desktop with no mail client configured simply does nothing when the link is followed. Keep copy-link adjacent so there is always a working alternative.
sms: separator differs by platform. iOS historically wants sms:&body=, Android sms:?body=. The sms:?&body= form above works on both.
Popup blockers. Opening a destination in a new tab must happen synchronously inside the click, exactly like navigator.share. Set href ahead of time and let the anchor navigate rather than calling window.open after an await.
target="_blank" without rel="noopener" hands the destination a reference to your window. Always pair them.
Files cannot be handed to another app. No web API allows it. Offer a download or upload-then-share-a-link instead; this is the one capability the native sheet keeps to itself.
Step 7 — Handle the Sheet’s Own Failure Modes
A custom sheet has failure modes the native one does not, because every action in it is yours to get wrong.
The clipboard write can reject. navigator.clipboard.writeText requires a secure context and, in some engines, transient user activation — so a copy triggered from a timer or after an await fails even though the button was clicked. Keep the write synchronous within the handler and treat a rejection as a prompt to select the text manually rather than as an error.
A destination can dead-end. mailto: on a machine with no mail client, or a messaging web client the user is not signed in to, produces a blank tab and no feedback. There is no event to observe this, which is precisely why copy-link must stay visible rather than being hidden behind a “more” affordance.
The sheet can outlive its payload. If the user opens the sheet, navigates within a single-page application, and then taps a destination, the pre-built href still points at the previous page. Rebuild the links each time the dialog opens rather than once at startup:
// refresh-on-open.js
export function openWithFreshLinks(dialog, buildTargets, payload, triggerEl) {
const urls = buildTargets(payload); // rebuilt per open, never cached
for (const anchor of dialog.querySelectorAll('[data-target]')) {
anchor.href = urls[anchor.dataset.target];
}
dialog.returnValue = '';
dialog.showModal();
dialog.querySelector('[data-action="copy"]')?.focus();
return triggerEl;
}
The payload can be empty. A canonical URL resolved from an async source may not have arrived when the sheet opens. Guard the open itself: a sheet full of links to undefined is worse than a briefly disabled button.
Together these are the reason the copy-to-clipboard fallback patterns belong underneath the whole design rather than beside it — every other action in the sheet has a way to fail silently, and that one does not.
Testing and Verification
Operate the whole sheet with the keyboard alone: Tab must cycle within the dialog, Escape must close it, and focus must land back on the trigger. Run a screen reader through the open dialog and confirm the heading is announced and the copy confirmation reaches the live region. Toggle reduce motion at the OS level and verify the sheet appears without animation. Finally, load the page with navigator.share deleted — the technique in simulating unsupported browsers in tests — and confirm the custom sheet is what appears, with the label having said so from the start.
Keep It Small Enough to Maintain
A custom sheet is code you own forever, against endpoints other companies change without notice. Four destinations chosen from real outbound-click data will be maintained; twelve chosen from a logo grid will not.
FAQ
Should a custom sheet replace navigator.share everywhere?
No. The native sheet knows which apps the user actually has installed and can hand over files, which no web UI can match. Use the native path whenever it is available and treat the custom sheet as the fallback for browsers without it — which is most of the desktop.
Do I need third-party share buttons or SDKs?
No, and they are worth avoiding. Every major destination accepts a plain URL with query parameters, so a handful of anchor elements does the same job with no third-party script, no tracking, and no render-blocking request.
Why use the dialog element instead of a div?
showModal gives you focus containment, Escape-to-close, the top layer and inertness for the rest of the page for free. Reimplementing those on a div is where custom modals usually go wrong, and the failures are exactly the ones keyboard and screen-reader users hit.
Can a custom sheet share files?
Not to another application — the web has no way to hand a file to an arbitrary installed app. Offer a download instead, or upload the file and share the resulting link. File hand-off is the one capability that remains exclusive to the native share sheet.