Back to News & Insights
JavaScript August 27, 2026 · 7 min read

The native share sheet is three strings and two fallbacks

Every mobile app has that button. You tap it, a tray slides up from the bottom of the screen, and...

The native share sheet is three strings and two fallbacks

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.

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.

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.

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

This is the entire share handler, and its shape is the only real decision in the file:

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.

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:

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.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation