Every mobile app has that button. You tap it, a tray slides up from the bottom of the screen, and there's WhatsApp, Messages, AirDrop, Notes, Discord, whatever you happen to have installed. For years that tray was native-only territory, and the web's answer was a row of brand icons opening twitter.com/intent/tweet in a new tab.
The tray is available to web pages now. navigator.share() opens the exact same OS sheet, and the happy path is six lines.
I shipped it last week on Forgemage.net, a marketplace for Dofus smithmagie — players post gear they want enchanted, and the people who do the enchanting claim the job. Somebody opens a request, wants to send it to a friend who might take it, and until last week the only way to do that was selecting the URL bar with a thumb. Now there's a Share button next to Archive.
The six lines were the easy part. What took the afternoon was everything around them.
The API is three strings and a promise
await navigator.share({
title: 'Magging Request Details',
text: 'Someone wants a Gelano enchanted',
url: 'https://forgemage.net/request/01JQ...',
});
Three optional strings, plus a files array. No SDK, no app ID, no OAuth dance, no script tag from a company that would like to A/B test my page for me.
Two hard requirements. The page has to be a secure context, so HTTPS in production and localhost while you work. And the call has to happen inside a real user gesture: not on DOMContentLoaded, not in a setTimeout, and — this is the one that catches people — not after an await that goes to the network. Fetch a short link from your own API and then call share(), and the transient activation is gone and the browser rejects you. Whatever you're going to share has to be sitting in memory before the click.
Where the URL comes from decides whether the link works
Why not just share location.href? Because the address bar isn't reliably the thing worth sending. My request detail route is localized, so the same page lives at /request/{ulid} in English, /requete/{ulid} in French and /pedido/{ulid} in Spanish. Several of the interesting entry points are dashboard views whose visible URL carries query state nobody else needs.
So the URL comes from the server, in the markup:
<button type="button" class="rd-cta rd-cta--ghost rd-share"
data-share-url="{{ url('app_request_detail', {'ulid': fmRequest.ulid}) }}"
data-share-title="{{ 'Magging Request Details'|trans }}"
data-copied-label="{{ 'Copied'|trans }}">
<i class="bi bi-share-fill"></i>
<span class="rd-share__label">{% trans %}Share{% endtrans %}</span>
</button>
The important character in there is the u in url(). Symfony's path() helper generates /request/01JQ…, which is perfect for an href and useless in a share payload: pasted into Discord it isn't a link, and a native target has no idea what host to prepend. url() gives the absolute form. One letter between a working feature and a bug report that says "the link doesn't open".
Every framework has this pair. If you're in Rails it's _path versus _url; in Next.js it's whatever you forgot to prefix with NEXT_PUBLIC_SITE_URL. The share payload is one of the few places in a web app where a relative URL is not just suboptimal but meaningless.
The rest of those attributes exist because JavaScript can't reach the Twig translator. The label, the confirmation string and the share title are rendered server-side into data-* and read back at click time. Slightly ugly, and the alternative is shipping a translation catalogue to the client so a button can say "Copied" in three languages.
One fallthrough handles every failure
This is the entire share handler, and its shape is the only real decision in the file:
async function share(button) {
const url = button.dataset.shareUrl || window.location.href;
if (navigator.share) {
try {
await navigator.share({ title: button.dataset.shareTitle || document.title, url });
return;
} catch (error) {
if (error.name === 'AbortError') {
return;
}
}
}
if (await copyToClipboard(url)) {
confirmCopy(button);
}
}
Two returns and no else. The success path returns, the cancel path returns, and everything else falls out of the if and lands on the clipboard: no navigator.share at all, a NotAllowedError from a lost gesture, a DataError from a URL that doesn't parse, a Permissions Policy blocking the call inside an iframe. I never had to enumerate the failures. I only had to name the two cases where doing nothing is the correct behaviour.
AbortError is the one worth being careful about. It's what you get when the user swipes the sheet away, and it arrives as a rejected promise looking exactly like a real problem. Catch it generically and you show "Sharing failed" to somebody who simply changed their mind, which is the kind of small thing that makes software feel like it isn't paying attention. Cancelling is a successful outcome of a share sheet. It just isn't a share.
And a correction I had to make to my own mental model while writing this up: the share sheet is not a phone feature. Chrome and Edge on Windows hand the payload to the Windows share flyout, and Safari on macOS opens the same sheet any Mac app gets. What decides it is whether the OS has a share UI and the browser bothers to wire it up. Linux doesn't have one, so navigator.share is undefined and my own Chrome copies the link instead. That was the only path I saw for the first hour of building this, and I spent a good chunk of it convinced I'd broken my own code. Firefox on desktop doesn't implement it anywhere.
The fallback has a fallback
navigator.clipboard needs a secure context and a permission that can be refused, and a copy button that silently does nothing is worse than no copy button:
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const field = document.createElement('textarea');
field.value = text;
field.setAttribute('readonly', '');
field.style.position = 'fixed';
field.style.opacity = '0';
document.body.append(field);
field.select();
const copied = document.execCommand('copy');
field.remove();
return copied;
}
}
Yes, execCommand is deprecated. It also works nearly everywhere and returns a boolean I can act on, which is more than the modern API offers when it refuses.
Note the opacity: 0 with position: fixed rather than display: none or a negative offset. The field has to be selectable, so it can be invisible but it can't be gone, and it shouldn't scroll the page on the way in. It also has to come out in the same tick, otherwise a slow frame leaves a real focusable element in the DOM for a screen reader to find.
Both paths return a boolean instead of throwing, which is why the caller reads as one clean if.
Telling the user it worked
On the native path they know, because the OS drew a sheet over the page. On the clipboard path nothing visible happens at all, so the button says it itself:
function confirmCopy(button) {
const label = button.querySelector('.rd-share__label');
const icon = button.querySelector('i');
const original = label.textContent;
button.classList.add('is-copied');
label.textContent = button.dataset.copiedLabel || 'Copied';
icon.className = 'bi bi-check2';
setTimeout(() => {
button.classList.remove('is-copied');
label.textContent = original;
icon.className = 'bi bi-share-fill';
}, 1800);
}
Icon becomes a checkmark, label becomes the translated "Copied", both go back after 1.8 seconds. No toast library, no notification container, no state store. The feedback lives on the element the user just touched, which is where their eyes already are.
Reading original off the DOM instead of hardcoding "Share" is what makes the restore correct in French and Spanish. And 1800 is a number I picked because 1000 felt clipped and 3000 felt like the button was stuck.
What I left out on purpose
The payload is title and url, nothing else. The spec is explicit that all three fields are hints: the receiving app decides what to do with them, and several targets take exactly one of text or url and drop the other on the floor. Passing both is how you end up with a message that describes a request without linking to it. If the link is the point, send the link.
title is mostly ignored too. Android Chrome uses it as the subject line for mail targets and discards it elsewhere. I pass it because when it does get read it's the right string, and when it doesn't, nothing is lost.
Files would be the obvious next step:
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file] });
}
canShare() is the only honest way to find out whether the platform accepts that MIME type and that size, and it shipped later than share() itself, hence the optional call. I skipped all of it. Rendering an image server-side, prefetching it before the click so the user gesture survives, and accepting that several platforms ignore url when files is present is a feature, not a fallback. Different afternoon.
Same for the other direction: a PWA can register itself inside the sheet with share_target in its manifest and receive a plain multipart POST, which any backend already knows how to read. Android only, nothing on iOS.
What's still broken
The shared link hits a login wall. The controller calls denyAccessUnlessGranted('ROLE_USER'), so a friend who taps the link without an account lands on a login page instead of the request. Correct for the data, rough as a first impression for a link somebody chose to send. A public teaser view would fix it and I haven't built one.
The locale rides along. url() generates the path for the sharer's locale, so a French user sharing to a Spanish friend sends them /requete/…, and the app serves it in French. Sharing arguably wants a locale-neutral canonical URL. I send the localized one because that's what the route helper gives you without thinking about it.
And I have no idea whether anyone uses the button. No event, no counter, nothing. The honest version of this post opens with a number instead of a story.
This article was originally published by DEV Community and written by Tom Girou.
Read original article on DEV Community