Web Share API¶
The Web Share API lets a page hand text, a URL and files to the operating system's native share sheet with one call, navigator.share(), so the user can send them to any installed app: a messenger, email, notes, AirDrop or a nearby device. It is a W3C Recommendation, it ships in Safari, in Chromium on Android, Windows, ChromeOS and macOS, and in Firefox for Android, and it replaces rows of third-party share buttons with the UI users already know. The API is small, but the details decide whether it works: it needs a fresh user gesture, validates URLs and files differently in each engine, is blocked in cross-origin iframes by default, and on Chromium enforces a file-type allowlist and size limits that canShare() does not report.
Key takeaways
navigator.share({title, text, url, files})returns a promise that resolves once the data reaches the chosen target or the OS. It rejects withAbortErrorwhen the user dismisses the sheet, which is not an error to report.- A call requires transient user activation and consumes it before validating anything: one share per click, within about 5 seconds of the gesture in Chromium, WebKit and Gecko. Download files before the click.
canShare()checks the Permissions Policy, the presence of known fields and the URL scheme. In Chromium it does not check file types, file count or size:canShare({files: [exe]})returnstrueandshare()then rejects withNotAllowedError.- Chromium permits only an allowlist of about 40 image, audio, video, text and PDF types, at most 10 files and 50 MiB in total. Firefox does not share files at all.
- The
web-sharePermissions Policy feature defaults to'self'. Cross-origin iframes needallow="web-share"; in a disallowed framenavigator.sharestill exists, butcanShare()returnsfalse. - Support: Safari 12.1+ (files from 14), Chrome for Android 61+, Chrome on Windows and ChromeOS 89+ and macOS 128+, Firefox for Android 79+. There is no support in Chrome on Linux, Android WebView or Firefox desktop (behind a flag).
- Always ship a fallback: copy-link via the Async Clipboard API plus plain share-intent links.
How a share works, end to end¶
The page describes what to share; the browser and the operating system decide where it goes. The page never learns which targets exist or which one the user picked, a deliberate privacy property of the specification: exposing the list of installed share targets would be a fingerprinting vector.
sequenceDiagram
participant U as User
participant P as Page
participant B as Browser
participant OS as OS share sheet
participant T as Target app
U->>P: click / tap / key press
P->>B: navigator.share(data)
B->>B: fully active? policy allows? no pending share?
B->>B: consume transient activation
B->>B: validate data, check files
B->>OS: show native share UI
U->>OS: pick a target (or dismiss)
OS->>T: deliver title, text, url, files
B-->>P: promise resolves (or AbortError) The promise settles when the data has been handed off. The specification calls share() "fire and forget": it does not wait for the target to approve the payload, so a resolved promise does not mean the message was sent, only that the user chose a target and the data was transmitted to it.
The API surface¶
The API adds two methods to Navigator. Both are exposed only in secure contexts and only on Window (there is no WorkerNavigator.share).
partial interface Navigator {
[SecureContext] Promise<undefined> share(optional ShareData data = {});
[SecureContext] boolean canShare(optional ShareData data = {});
};
dictionary ShareData {
sequence<File> files;
USVString title;
USVString text;
USVString url;
};
The ShareData dictionary¶
| Member | Type | Meaning | Notes |
|---|---|---|---|
title | USVString | Title of the shared document | "May be ignored by the target." Email apps usually map it to the subject; most messengers drop it. |
text | USVString | Body of the message | Free text. Do not repeat the URL here; several platforms concatenate text and url themselves. |
url | USVString | A URL referring to the shared resource | Parsed against the document's base URL, then serialized. Relative URLs are allowed; "" means the current page. |
files | sequence<File> | Files to share | Not supported in Firefox. Chromium restricts types, count and size (see Sharing files). |
All members are optional, but at least one of them must be present, and files only counts when it is a non-empty array. Members typed USVString are converted with lone surrogates replaced by U+FFFD, so malformed UTF-16 never reaches a native target.
navigator.share(data)¶
share() returns Promise<undefined>. Because it is a promise-returning IDL operation, every failure, including the synchronous checks, surfaces as a rejected promise rather than a thrown exception. The specification runs these steps, in this order:
- If the document is not fully active (for example, it is in the back/forward cache or its iframe was removed), reject with
InvalidStateError. - If the document is not allowed to use the
web-sharefeature (Permissions Policy), reject withNotAllowedError. - If a previous share from this
Navigatoris still pending, reject withInvalidStateError. - If the window does not have transient activation, reject with
NotAllowedError. - Consume user activation.
- Validate the share data against the API base URL; on failure reject with
TypeError. - If
urlis present, parse it and replace it with the serialized absolute URL. - If a file type is blocked for security reasons, reject with
NotAllowedError. - Otherwise show the share UI in parallel. If no targets are available, or the user dismisses the UI, reject with
AbortError. If starting the target or transmitting the data fails, reject withDataError. When the data reaches the target, resolve withundefined.
Steps 4 to 6 have a practical consequence that trips up many implementations: activation is consumed before validation. A call with invalid data rejects with TypeError and uses up the gesture, so a retry in the same handler rejects with NotAllowedError. Validate with canShare() first, then call share() once.
navigator.canShare(data)¶
canShare() is synchronous, returns a boolean, never needs user activation and never shows UI. It returns false if the document is not fully active or not allowed to use web-share, and otherwise returns the result of the same validate share data algorithm that share() uses:
- If none of
title,text,urlandfilesis present, returnfalse. - If
filesis present and empty and no other member is present, returnfalse. - If
filesis present and the implementation does not support file sharing, returnfalse. - If the user agent believes a file would make a "hostile share" (malicious content, excessive size), it may return
false. - If
urlis present: parse it against the base URL. Returnfalseif parsing fails, if the scheme is a local scheme (about:,blob:,data:),file:,javascript:,ws:orwss:, or if the scheme is not one the user agent considers shareable (httpandhttps, plus any scheme on its safelist). - Return
true.
The specification warns that canShare() "may return false positive results when used with objects that contain members not defined in the ShareData dictionary": unknown keys are ignored by the dictionary conversion, so canShare({title: "x", image: blob}) is true even though image means nothing. canShare({image: blob}) alone is false because no known member remains.
Step 4 is optional, and Chromium does not implement it in canShare(): Blink's canShare() checks only the Permissions Policy, the presence of a known member and the URL (which must be in the http/https family or use the document's own scheme). File types, counts and sizes are checked later, inside share() and in the browser process. The table follows from Blink's navigator_share.cc and share_service_impl.cc, on a desktop platform with Web Share enabled:
| Call | canShare() result | What share() then does (with activation) |
|---|---|---|
{files: [x.png as image/png]} | true | Opens the share sheet |
{files: [x.exe]} | true | Rejects NotAllowedError: Permission denied |
{files: [x.png as application/octet-stream]} | true | Rejects NotAllowedError: Permission denied |
{files: [11 PNG files]} | true | Rejects NotAllowedError, console warns "Share too large" |
{text: 1 MiB + 1 characters} | true | Rejects NotAllowedError, console warns "Share too large" |
{files: []} | false | Rejects TypeError |
{files: [], title: "a"} | true | Shares the title only |
{url: "/foo"} | true | Shares the absolute URL |
{url: ""} | true | Shares the current page URL |
{url: "ftp://example.com/"} | false | Rejects TypeError: Invalid URL |
{url: "mailto:[email protected]"} | false | Rejects TypeError: Invalid URL |
{url: "data:text/plain,hi"} | false | Rejects TypeError: Invalid URL |
{} or no argument | false | Rejects TypeError: No known share data fields supplied… |
Treat canShare() as "the data is well-formed and sharing is allowed in this frame", not as "the share will succeed". Where file sharing matters, run your own preflight against the limits in the next sections.
When canShare() is missing
canShare() arrived later than share(): Safari 14, Chrome for Android 75, Firefox for Android 96. In an engine that has share() but no canShare(), it does not support files either, so the safe interpretation of navigator.canShare?.(data) !== false is "try share() with text and URL only, and handle the rejection".
Exceptions and their real-world causes¶
The specification defines five rejection types. The engines agree on the types but not on every cause, and the messages differ. The messages below are copied from the current Chromium, WebKit and Firefox sources (September 2026).
| Exception | Specification cause | Chromium | WebKit (Safari) | Gecko (Firefox for Android) |
|---|---|---|---|---|
InvalidStateError | Document not fully active, or a share already pending | "An earlier share has not yet completed." (not raised on Android, see below) | "share() is already in progress" | Raised for a pending share, no message |
NotAllowedError | Policy disallows web-share; no transient activation; blocked file | "Must be handling a user gesture to perform a share request."; "Permission denied" for policy, file type, file count, size, unsafe file names and a hidden or background tab; "Web Share is not allowed in a fenced frame tree." | "Third-party iframes are not allowed to call share() unless explicitly allowed via Feature-Policy (web-share)"; no message for missing activation | "User activation was already consumed or share() was not activated by a user gesture."; "Document's Permissions Policy does not allow calling share() from this context." |
TypeError | Share data fails validation | "No known share data fields supplied. If using only new fields (other than title, text and url), you must feature-detect them first."; "Invalid URL" | Raised with no message | "Passing files is currently not supported."; "Must have a title, text, or url member in the ShareData dictionary" |
AbortError | User dismissed the UI, or no targets available | "Share canceled"; internal failures also reject as AbortError with "Share failed" | "Abort due to cancellation of share." | Raised on cancellation |
DataError | Starting the target or transmitting data failed | Not used: Chromium maps internal errors to AbortError | Not used: an incomplete share rejects with AbortError | Not used in practice |
Three implementation details are worth knowing:
- Chromium on Android skips the "share already in progress" check. A comment in Blink's
navigator_share.ccexplains that Android does not always report when a share completes, so a stale pending state would block every later share. On Android a second call rejects withNotAllowedErrorfor lack of activation instead. - Chromium requires the tab to be visible and active. The browser process rejects with
NotAllowedErrorif the page is in a background tab or hidden when the request arrives, and again when the share UI is about to open. - A pending share disables the back/forward cache for the page in Chromium, so navigating away while the sheet is open never restores a page with a dangling promise.
Because the messages are implementation-specific and localized in some engines, branch on error.name, never on error.message.
The user activation requirement¶
share() is a transient activation-consuming API in HTML's user activation model. The window gains transient activation when the browser dispatches an activation-triggering input event: a trusted keydown (except the Esc key and keys reserved by the browser), mousedown, pointerdown with pointerType "mouse", pointerup with any other pointerType, or touchend. A click handler therefore runs with activation, because the mousedown/pointerup that preceded it already granted it. Handlers for load, scroll, mouseover, focus, timers and messages do not.
Transient activation expires after the transient activation duration, which HTML leaves implementation-defined ("at most a few seconds"). All three engines currently use 5 seconds: Chromium's kActivationLifespan, WebKit's defaultTransientActivationDuration and Firefox's dom.user_activation.transient.timeout preference are all set to 5 seconds. Calling share() consumes the activation immediately, whatever happens next, so the pattern "try with files, and on failure retry without them" can never work inside one gesture.
// Broken: the network round trip can outlive the 5-second activation window,
// and on slow connections share() rejects with NotAllowedError.
button.addEventListener("click", async () => {
const blob = await (await fetch("/exports/report.pdf")).blob(); // may take seconds
await navigator.share({ files: [new File([blob], "report.pdf", { type: "application/pdf" })] });
});
// Correct: prepare the File ahead of time, keep share() the first slow step after the click.
const reportFile = fetch("/exports/report.pdf")
.then((r) => (r.ok ? r.blob() : Promise.reject(new Error(r.statusText))))
.then((blob) => new File([blob], "report.pdf", { type: "application/pdf" }));
button.addEventListener("click", async () => {
const file = await reportFile; // already settled in the common case
const data = { files: [file], title: "Quarterly report" };
if (!navigator.canShare?.(data)) return fallback(data);
try {
await navigator.share(data);
} catch (err) {
if (err.name !== "AbortError") fallback(data);
}
});
navigator.userActivation (Chrome 72, Safari 16.4, Firefox 120) exposes the state for diagnostics: isActive is transient activation, hasBeenActive is sticky activation. Logging navigator.userActivation.isActive just before share() is the fastest way to prove that an await chain ate the gesture. Do not use it to decide whether to call share(); call it from the handler and handle the rejection.
Other APIs consume the same activation. A handler that first calls window.open(), requestFullscreen() or a payment sheet and then share() leaves nothing for the second call. The HTML model and the other activation-gated capabilities are covered on the section overview.
Sharing files¶
File sharing arrived as "Web Share Level 2" and is now part of the single specification. The mechanics are simple: put File objects in files. The constraints differ by engine.
Chromium's file-type allowlist¶
Chromium only shares files whose name extension and MIME type both appear on a fixed allowlist, maintained in third_party/blink/renderer/modules/webshare/FILE_TYPES.md and enforced in the browser process on desktop (share_service_impl.cc) and in the Android share service. The extension check is a case-insensitive suffix match on File.name; the MIME check is an exact match on File.type. A PNG named photo.png but typed application/octet-stream is rejected, and so is a correctly typed file named photo with no extension.
| Category | Extensions | MIME types |
|---|---|---|
| Application | .pdf | application/pdf |
| Audio | .flac, .m4a, .mp3, .oga, .ogg, .opus, .wav, .weba | audio/flac, audio/x-m4a, audio/mpeg, audio/mp3, audio/ogg, audio/wav, audio/webm |
| Image | .avif, .bmp, .gif, .ico, .jfif, .jpeg, .jpg, .pjp, .pjpeg, .png, .svg, .svgz, .tif, .tiff, .webp, .xbm | image/avif, image/bmp, image/x-ms-bmp, image/gif, image/x-icon, image/jpeg, image/png, image/svg+xml, image/tiff, image/webp, image/x-xbitmap |
| Text | .css, .csv, .ehtml, .htm, .html, .shtm, .shtml, .text, .txt | text/css, text/csv, text/comma-separated-values, text/html, text/plain |
| Video | .m4v, .mp4, .mpeg, .mpg, .ogm, .ogv, .webm | video/mp4, video/mpeg, video/ogg, video/webm |
The list changes rarely (.avif is a later addition), so re-check FILE_TYPES.md when you depend on a specific type. Types that are not on the list, including .zip, .json, .heic, .docx, .gpx and anything executable, cannot be shared from Chromium at all. On desktop, Chromium additionally consults Safe Browsing about the sharing page when a shared file's type is one that download protection inspects, and denies the share if the page is flagged.
WebKit's Navigator::share() has no equivalent allowlist: it reads the files and hands them to the system share sheet, which decides which targets can take them. Gecko rejects any non-empty files array with TypeError.
Size and count limits¶
Blink enforces these limits in the renderer before contacting the browser process, and the Android share service enforces the file limits again:
| Limit | Value in Chromium | Measured as |
|---|---|---|
| Files per share | 10 | files.length |
| Total file size | 50 MiB (52,428,800 bytes) | Sum of File.size |
title length | 16 KiB (16,384) | UTF-16 code units (String.length) |
text length | 1 MiB (1,048,576) on desktop, 120 KiB (122,880) on Android | UTF-16 code units |
url length | 16 KiB (16,384) | UTF-16 code units, before resolution |
Exceeding any of them rejects with NotAllowedError ("Permission denied") and logs "Share too large" to the console. None of them are part of the specification, and none of them are visible to canShare(), which is why a production share button needs its own preflight:
// Mirrors Chromium's checks (share_service_impl.cc, navigator_share.cc).
// Other engines are more permissive; failing this check is a reason to degrade, not to block.
const CHROMIUM_EXTENSIONS = new Set([
"pdf", "flac", "m4a", "mp3", "oga", "ogg", "opus", "wav", "weba",
"avif", "bmp", "gif", "ico", "jfif", "jpeg", "jpg", "pjp", "pjpeg", "png", "svg", "svgz",
"tif", "tiff", "webp", "xbm", "css", "csv", "ehtml", "htm", "html", "shtm", "shtml",
"text", "txt", "m4v", "mp4", "mpeg", "mpg", "ogm", "ogv", "webm",
]);
const CHROMIUM_MIME_TYPES = new Set([
"application/pdf", "audio/flac", "audio/mp3", "audio/mpeg", "audio/ogg", "audio/wav",
"audio/webm", "audio/x-m4a", "image/avif", "image/bmp", "image/gif", "image/jpeg",
"image/png", "image/svg+xml", "image/tiff", "image/webp", "image/x-icon", "image/x-ms-bmp",
"image/x-xbitmap", "text/comma-separated-values", "text/css", "text/csv", "text/html",
"text/plain", "video/mp4", "video/mpeg", "video/ogg", "video/webm",
]);
const MAX_FILES = 10;
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
/** Returns a list of human-readable problems; an empty list means "likely shareable". */
export function preflightFiles(files) {
const problems = [];
if (files.length > MAX_FILES) problems.push(`Too many files (${files.length} > ${MAX_FILES}).`);
const total = files.reduce((sum, f) => sum + f.size, 0);
if (total > MAX_TOTAL_BYTES) problems.push(`Files are too large (${total} bytes in total).`);
for (const file of files) {
const ext = file.name.includes(".") ? file.name.split(".").pop().toLowerCase() : "";
if (!CHROMIUM_EXTENSIONS.has(ext)) problems.push(`${file.name}: extension not shareable.`);
if (!CHROMIUM_MIME_TYPES.has(file.type)) problems.push(`${file.name}: type "${file.type}" not shareable.`);
}
return problems;
}
Creating File objects to share¶
Anything that produces a Blob can be shared, as long as you wrap it in a File with a correct name and type:
// From a canvas: an image the user just edited.
export function canvasToFile(canvas, name = "image.png", type = "image/png", quality) {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(new File([blob], name, { type, lastModified: Date.now() }))
: reject(new Error("Canvas could not be encoded"))),
type,
quality
);
});
}
// From the network: the response must be same-origin or CORS-enabled,
// otherwise the body is opaque and cannot be read into a Blob.
export async function urlToFile(url, name) {
const response = await fetch(url, { mode: "cors", credentials: "same-origin" });
if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`);
const blob = await response.blob();
// Trust the server's Content-Type only if it is a real media type.
const type = blob.type.split(";")[0].trim() || "application/octet-stream";
return new File([blob], name, { type });
}
// From text you generate: CSV export.
export function csvFile(rows, name = "export.csv") {
const escape = (v) => `"${String(v).replaceAll('"', '""')}"`;
const body = rows.map((r) => r.map(escape).join(",")).join("\r\n");
return new File([body], name, { type: "text/csv" });
}
Files selected with <input type="file"> are already File objects and can be passed straight through, subject to the same allowlist. Chromium also rejects a file whose name is not a safe base name (a name containing path separators), logging "Unsafe file name".
When you share files, targets decide which other fields to use. Many ignore title, and some drop text or url when files are present. Put anything essential in the file itself, and test with the apps your users actually use.
What each platform does with the data¶
ShareData is converted into the platform's native share payload, and the conversions are lossy in ways that affect how your share looks at the other end.
| Platform | Native mechanism | How the fields are mapped |
|---|---|---|
| Android (Chrome) | Android share intent via Chrome's share sheet | text and url are concatenated with a single space into one text payload (Chrome's ShareParams.getTextAndUrl()). A receiving app sees the title as the intent subject and the text-plus-URL as the intent text. |
| Windows (Chrome, Edge) | WinRT DataTransferManager share UI | title becomes the data package title, which Windows requires, so Chromium passes a single space when you omit it. text is set with SetText(), url with SetWebLink(). Chromium exits HTML fullscreen before showing the share UI. |
| macOS (Chrome 128+, Safari) | The system sharing service and share menu | Title, text, URL and files are offered to macOS sharing services such as Mail, Messages, Notes and AirDrop. |
| ChromeOS | ChromeOS sharesheet | Shared files are written to a temporary directory and offered to installed apps, including web apps that declare a share target. |
| iOS and iPadOS (Safari and WebKit-based browsers) | The system share sheet | The same sheet native apps use, including AirDrop and share extensions. |
Two conclusions follow. First, do not put the URL in text as well: on Android the recipient would get it twice. Second, do not rely on title: on Android it usually does not appear in chat apps at all. If the title matters, put it at the start of text.
Permissions Policy and iframes¶
Web Share is controlled by the web-share Permissions Policy feature, whose default allowlist is 'self'. The top-level document and same-origin iframes may share; cross-origin iframes may not, unless the embedder delegates the feature:
<!-- Delegate Web Share to one embedded origin. -->
<iframe src="https://player.example/embed/123" allow="web-share"></iframe>
<!-- Equivalent, explicit origin list. -->
<iframe src="https://player.example/embed/123" allow="web-share https://player.example"></iframe>
Permissions-Policy: web-share=(self "https://player.example")
To switch the API off entirely on your own pages, for example on an admin console where sharing could leak internal URLs, send Permissions-Policy: web-share=().
Chromium started enforcing the 'self' default in Chrome 110; before that, cross-origin iframes could call share() freely. Firefox recognizes the web-share token in allow from version 81. WebKit checks the policy too (its rejection message tells third-party iframes to use web-share delegation), although MDN's compatibility data does not list Safari support for the allow token as of September 2026, so test embedded sharing on Safari explicitly. Neither Safari nor Firefox supports the Permissions-Policy header, so the allow attribute is the portable way to delegate. Chromium also refuses to share from fenced frames.
In a frame where the policy disallows sharing, navigator.share still exists (typeof navigator.share === "function"), so a naive feature check shows a share button that can never work. canShare() does check the policy: in Blink it returns false before looking at the data when web-share is not enabled for the frame. For a cross-origin iframe in Chromium that means:
| Frame | typeof navigator.share | navigator.canShare({url}) |
|---|---|---|
Cross-origin iframe, no allow | "function" | false |
Cross-origin iframe, allow="web-share" | "function" | true |
Do not use document.featurePolicy for web-share
Chromium's legacy document.featurePolicy API is Chromium-only, and the web-share policy feature depends on a runtime feature that Chrome enables per platform (it is off on Linux and in WebView), so document.featurePolicy.allowsFeature("web-share") is not a dependable signal. navigator.canShare({ url: location.href }) is the reliable, cross-browser way to ask "may this frame share?".
Browser support¶
| Browser | share() (text, URL) | files | canShare() | Notes |
|---|---|---|---|---|
| Chrome for Android | ✅ 61 | ✅ 76 | ✅ 75 | Not in Android WebView |
| Chrome on Windows and ChromeOS | ✅ 89 | ✅ 89 | ✅ 89 | |
| Chrome on macOS | ✅ 128 | ✅ 128 | ✅ 128 | |
| Chrome on Linux | ❌ | ❌ | ❌ | navigator.share is not exposed |
| Edge | ✅ 81 (Windows) | ✅ 81 | ✅ 81 | ⚠️ MDN lists full support from Edge 931 |
| Safari on macOS | ✅ 12.1 | ✅ 14 | ✅ 14 | |
| Safari on iOS/iPadOS | ✅ 12.2 | ✅ 14 | ✅ 14 | Also WebKit-based browsers on iOS |
| Samsung Internet | ✅ 8.0 | ✅ 11.0 | ✅ 11.0 | |
| Firefox for Android | ✅ 79 | ❌ | ✅ 96 | canShare({files}) returns false |
| Firefox desktop | 🧪 flag | ❌ | 🧪 flag | dom.webshare.enabled; on by default only in Nightly on Windows |
web-share policy enforced for iframes | ✅ Chrome 110 | Firefox 81 recognizes the token; see above for Safari |
Support data as of September 2026. For live data see MDN's Navigator.share() compatibility table and caniuse.
Chromium's absence on Linux is structural: Blink's runtime flag for Web Share is enabled only on Android, Windows, ChromeOS and macOS, "to prevent making the API available to Linux and WebView", and the desktop share service has no Linux implementation. Android WebView likewise does not expose the API; a native app embedding your site in a WebView has to provide its own bridge to the Android share intent.
Fallbacks when native sharing is unavailable¶
Several environments have no working navigator.share(): Chrome on Linux, Firefox desktop, Android WebView and cross-origin iframes without delegation. Hiding the share button there is acceptable for secondary actions; for core actions, show a fallback that does the two things users actually want: copy the link, or open a specific service.
Copy the link with the Async Clipboard API¶
navigator.clipboard.writeText() is the simplest fallback. It requires a secure context and, in Firefox and Safari, a user gesture; Chromium requires either a gesture or the clipboard-write permission, and the document must be focused. Inside a cross-origin iframe the clipboard-write Permissions Policy feature must be delegated, just like web-share.
export async function copyLink(url, inputForManualCopy) {
try {
await navigator.clipboard.writeText(url);
return "copied";
} catch (err) {
// NotAllowedError: no gesture, unfocused document or policy. Let the user copy by hand.
inputForManualCopy.value = url;
inputForManualCopy.focus();
inputForManualCopy.select();
return "manual";
}
}
document.execCommand("copy") still works in most browsers but is deprecated; a pre-selected read-only <input> with a hint to press Ctrl+C or Cmd+C is a better last resort.
Share-intent URLs¶
Most large services accept a prefilled post or message through a plain URL. These are not standards, and each service can change or retire its endpoint, so keep them in one table in your code and check them occasionally.
| Service | URL template | Notes |
|---|---|---|
mailto:?subject={title}&body={text}%20{url} | Opens the default mail client; no target="_blank" | |
| X | https://x.com/intent/post?text={text}&url={url} | twitter.com/intent/tweet redirects here |
| Bluesky | https://bsky.app/intent/compose?text={text}%20{url} | Text only; include the URL in text |
https://www.linkedin.com/sharing/share-offsite/?url={url} | Reads the page's Open Graph tags for the preview | |
https://www.facebook.com/sharer/sharer.php?u={url} | Reads Open Graph tags | |
https://wa.me/?text={text}%20{url} | Opens the app on mobile, WhatsApp Web on desktop | |
| Telegram | https://t.me/share/url?url={url}&text={text} | Both values must be URL-encoded |
Encode every value with encodeURIComponent(). Open web intents in a new tab with rel="noopener noreferrer" so the service cannot script your page through window.opener, and so your page URL does not leak through Referer beyond what you put in the query string.
Choosing between hide, degrade and replace¶
- Hide the control when
navigator.canShare?.({ url }) !== trueand the action is optional (a share icon on every card of a feed). - Degrade to copy-link plus intents when sharing is the primary call to action (a "Share your results" screen). The component below does this.
- Replace files with a link when file sharing is unavailable: upload the file and share its URL, or let the user download it. Firefox for Android shares text and URLs but not files, so this is the common case there.
A complete share component¶
The custom element below puts the pieces together: it prefetches files so the click handler never waits on the network, validates with canShare(), calls share() once per gesture, treats AbortError as a cancellation, and opens an accessible <dialog> with copy-link and share-intent fallbacks when native sharing is unavailable or fails. It reports the outcome through DOM events so analytics stays outside the component. The Playwright tests after it cover the native path and the fallback with a stubbed navigator.share.
/**
* <share-button>: Web Share with prefetched files and a fallback dialog.
*
* <share-button url="/posts/42" title="Post title" text="Short summary"
* files="/media/cover.jpg">Share</share-button>
*
* Events (bubbling): "share-done" (detail.method = "native" | "copy" | "intent"),
* "share-cancel", "share-error" (detail.error).
*/
const INTENTS = [
{ id: "email", label: "Email", href: (d) => `mailto:?subject=${enc(d.title)}&body=${enc(join(d.text, d.url))}` },
{ id: "x", label: "X", href: (d) => `https://x.com/intent/post?text=${enc(d.text || d.title)}&url=${enc(d.url)}` },
{ id: "bluesky", label: "Bluesky", href: (d) => `https://bsky.app/intent/compose?text=${enc(join(d.text || d.title, d.url))}` },
{ id: "linkedin", label: "LinkedIn", href: (d) => `https://www.linkedin.com/sharing/share-offsite/?url=${enc(d.url)}` },
{ id: "facebook", label: "Facebook", href: (d) => `https://www.facebook.com/sharer/sharer.php?u=${enc(d.url)}` },
{ id: "whatsapp", label: "WhatsApp", href: (d) => `https://wa.me/?text=${enc(join(d.text || d.title, d.url))}` },
{ id: "telegram", label: "Telegram", href: (d) => `https://t.me/share/url?url=${enc(d.url)}&text=${enc(d.text || d.title)}` },
];
const enc = (value = "") => encodeURIComponent(value);
const join = (...parts) => parts.filter(Boolean).join(" ");
export class ShareButton extends HTMLElement {
#button = null;
#files = null; // Promise<File[]> once prefetching starts
#busy = false;
connectedCallback() {
if (this.#button) return;
this.#button = document.createElement("button");
this.#button.type = "button";
this.#button.append(...this.childNodes); // the element's content becomes the label
if (!this.#button.textContent.trim()) this.#button.textContent = "Share";
this.append(this.#button);
this.#button.addEventListener("click", () => this.#onClick());
// Fetch files before the click: transient activation expires after about 5 s. (1)!
if (this.getAttribute("files")) {
const prefetch = () => this.#prefetchFiles();
this.addEventListener("pointerenter", prefetch, { once: true });
this.addEventListener("focusin", prefetch, { once: true });
(window.requestIdleCallback ?? setTimeout)(prefetch);
}
}
get shareData() {
const url = this.getAttribute("url");
return {
title: this.getAttribute("title") ?? document.title,
text: this.getAttribute("text") ?? "",
// Resolve relative URLs here so fallbacks get the same absolute URL as share().
url: new URL(url ?? location.href, document.baseURI).href,
};
}
#prefetchFiles() {
this.#files ??= Promise.all(
(this.getAttribute("files") ?? "").split(/\s+/).filter(Boolean).map(async (src) => {
const response = await fetch(src);
if (!response.ok) throw new Error(`${src}: HTTP ${response.status}`);
const blob = await response.blob();
const name = new URL(src, location.href).pathname.split("/").pop() || "file";
return new File([blob], name, { type: blob.type, lastModified: Date.now() });
})
).catch((error) => {
console.warn("share-button: files unavailable, sharing without them", error);
return [];
});
return this.#files;
}
async #onClick() {
if (this.#busy) return; // a second share() while one is pending rejects anyway
const data = this.shareData;
// Only use files that are already downloaded; awaiting a download here
// could outlive the transient activation that share() needs. (2)!
const files = await Promise.race([this.#files ?? Promise.resolve([]), Promise.resolve(null)]);
if (files?.length && navigator.canShare?.({ files })) data.files = files;
if (typeof navigator.share === "function" && navigator.canShare?.(data) !== false) { // (3)!
this.#busy = true;
try {
await navigator.share(data);
this.#emit("share-done", { method: "native" });
return;
} catch (error) {
if (error.name === "AbortError") {
// User dismissed the sheet (or no targets exist). Not an error.
this.#emit("share-cancel", {});
return;
}
// NotAllowedError: blocked by policy, file type or size, or activation lost.
// TypeError: invalid data. InvalidStateError: a share is already open.
// DataError: the target failed. Offer the fallback UI instead.
this.#emit("share-error", { error });
} finally {
this.#busy = false;
}
}
this.#openFallback(data);
}
#openFallback(data) {
const dialog = document.createElement("dialog");
dialog.className = "share-fallback";
dialog.setAttribute("aria-label", "Share");
const heading = document.createElement("h2");
heading.textContent = "Share";
const field = document.createElement("input");
field.type = "url";
field.readOnly = true;
field.value = data.url;
field.setAttribute("aria-label", "Link to share");
const copy = document.createElement("button");
copy.type = "button";
copy.textContent = "Copy link";
copy.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(data.url); // needs a secure context and, in most browsers, a click
copy.textContent = "Copied";
this.#emit("share-done", { method: "copy" });
} catch {
field.focus();
field.select(); // let the user copy manually
copy.textContent = "Press Ctrl+C / ⌘C to copy";
}
});
const list = document.createElement("ul");
for (const intent of INTENTS) {
const link = document.createElement("a");
link.href = intent.href(data);
link.textContent = intent.label;
if (!link.href.startsWith("mailto:")) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
link.addEventListener("click", () => this.#emit("share-done", { method: "intent", target: intent.id }));
const item = document.createElement("li");
item.append(link);
list.append(item);
}
const close = document.createElement("button");
close.type = "button";
close.textContent = "Close";
close.addEventListener("click", () => dialog.close());
dialog.addEventListener("close", () => {
dialog.remove();
this.#button.focus(); // return focus to the trigger (4)!
});
dialog.append(heading, field, copy, list, close);
document.body.append(dialog);
dialog.showModal();
}
#emit(type, detail) {
this.dispatchEvent(new CustomEvent(type, { bubbles: true, detail }));
}
}
if (!customElements.get("share-button")) customElements.define("share-button", ShareButton);
- Prefetching starts on idle, and again (as a no-op thanks to
??=) on hover or focus. By the time the user clicks, theFileobjects normally exist, and the click handler only has to pick them up. Promise.race()with an already-resolved promise returns the files if they are ready andnullif they are still downloading, without blocking. The share then goes out without files instead of risking an expired activation.canShare()is optional-chained: in an engine withshare()but nocanShare()the comparison isundefined !== false, so the share is attempted and any rejection falls through to the dialog.showModal()traps focus inside the dialog and Esc closes it. Returning focus to the trigger onclosekeeps keyboard and screen reader users oriented.
Use it declaratively and wire analytics through the events:
<script type="module" src="/js/share-button.js"></script>
<share-button url="/posts/42" title="Service worker update patterns"
text="Four ways to ship a new service worker without breaking open tabs."
files="/media/posts/42/cover.png">
Share this article
</share-button>
<script type="module">
document.addEventListener("share-done", (e) => {
navigator.sendBeacon("/analytics", JSON.stringify({ event: "share", method: e.detail.method, target: e.detail.target }));
});
document.addEventListener("share-error", (e) => console.warn("Share failed:", e.detail.error.name));
</script>
share-button button { min-block-size: 44px; } /* comfortable touch target */
dialog.share-fallback { max-inline-size: min(28rem, 100vw - 2rem); border: none; border-radius: 12px; }
dialog.share-fallback::backdrop { background: rgb(0 0 0 / 0.4); }
dialog.share-fallback ul { display: grid; grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); gap: .5rem; padding: 0; list-style: none; }
dialog.share-fallback input { inline-size: 100%; }
Keep the share icon consistent with the platform where you can: users recognize the Android "share" glyph and the Apple "square with arrow" glyph. Some apps switch icons based on the user agent; if you do, fall back to a neutral label such as "Share" for unknown platforms. The broader patterns for app-like controls are on App-Like UX Patterns.
Testing and debugging Web Share¶
Automated tests cannot interact with a native share sheet, so split the problem in two: test your logic by stubbing navigator.share, and test the real integration by hand on each platform.
import { test, expect } from "@playwright/test";
test("shares prefetched files through the native API", async ({ page }) => {
await page.addInitScript(() => {
window.__shares = [];
// Replace the real methods before any page script runs. Stub canShare() too:
// Chromium on Linux (the usual CI host) exposes neither method.
Navigator.prototype.canShare = () => true;
Navigator.prototype.share = function (data) {
window.__shares.push({
active: navigator.userActivation.isActive, // proves the call happened inside the gesture
url: data.url,
files: (data.files ?? []).map((f) => `${f.name}:${f.type}`),
});
return Promise.resolve();
};
});
await page.goto("/article.html");
await page.hover("share-button button"); // triggers prefetch
await page.waitForLoadState("networkidle");
await page.click("share-button button"); // Playwright input is trusted, so it grants activation
const shares = await page.evaluate(() => window.__shares);
expect(shares).toHaveLength(1);
expect(shares[0].active).toBe(true);
expect(shares[0].files).toEqual(["cover.png:image/png"]);
});
test("falls back to the dialog when Web Share is missing", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.addInitScript(() => {
delete Navigator.prototype.share;
delete Navigator.prototype.canShare;
});
await page.goto("/article.html");
await page.click("share-button button");
await expect(page.locator("dialog.share-fallback")).toBeVisible();
await page.getByRole("button", { name: "Copy link" }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toMatch(/\/posts\/42$/);
});
Engine-specific behavior matters for test design:
- Chromium on a CI host exposes
navigator.share()only where the platform has an implementation (Windows, ChromeOS and macOS, not Linux), and a real call with valid data can stay pending because nothing dismisses the sheet. Always stub bothshare()andcanShare(), so the same test behaves identically on every host. - WebKit under WebDriver automation resolves
share()immediately without showing the sheet:Navigator::showShareData()checks whether the page is controlled by automation. A passing Safari WebDriver test proves only that your validation succeeded. - Firefox has a test-only preference,
dom.webshare.requireinteraction, that disables the activation requirement. It is useful for Gecko's own tests, not for yours.
For manual testing, remote-debug Android devices from chrome://inspect on a desktop Chrome and iOS devices from Safari's Develop menu. The console shows Chromium's "Share too large" and "Unsafe file name" warnings, which never appear in error.message. See Browser DevTools and Automated Testing for the general setup.
Common pitfalls¶
- Awaiting the network between the click and
share(). On a slow connection the 5-second activation window closes and the call rejects withNotAllowedError. Prefetch files and build the payload before the gesture. - Retrying inside the same handler. The first call consumed the activation, so any second call rejects. Decide what to share with
canShare()first. - Trusting
canShare()for files in Chromium. It returnstruefor any non-empty file list. Check the type allowlist, count and size yourself, or handleNotAllowedErrorwith a fallback. - Wrong MIME type on generated files.
new File([blob], "chart.png")without{type: "image/png"}has an empty type and fails Chromium's MIME check even though the extension is fine. - Reporting
AbortErroras a failure. It is the normal result of the user closing the sheet, and on some platforms of there being no targets. Count it as a cancellation. - Duplicated URLs. Putting the URL in both
textandurlproduces it twice on Android, where Chrome concatenates the two. - Showing the button in iframes that cannot share.
typeof navigator.share === "function"istrueeven when the Permissions Policy blocks the call; usecanShare(). - Sharing
blob:ordata:URLs. They are rejected withTypeErrorbecause they mean nothing outside your origin. Share the file itself, or upload it and share anhttps:URL. - Expecting file sharing in Firefox. Firefox for Android shares text and URLs only;
canShare({files})isfalsethere, so drop the files and share a link. - Calling
share()from a service worker or a background tab. It is not exposed in workers, and Chromium rejects shares from hidden or inactive tabs. - Keying on error messages. Messages differ per engine and can change; use
error.name.
Further reading¶
On this site
- Web Share Target: the receiving side, so your installed PWA appears in the share sheet
- Device & OS Integration: the user activation model, feature detection and the full capability matrix
- Permissions: Permissions Policy, delegation to iframes and permission prompts
- App-Like UX Patterns: designing native-feeling controls
- File System Access: letting users save files instead of sharing them
- API Cheat Sheet: the capability APIs on one page
- Automated Testing: Playwright and WebDriver setups for PWA features
External references
- Web Share API, W3C Recommendation and the editor's draft
- MDN:
Navigator.share()andNavigator.canShare() - HTML Standard: tracking user activation
- Chromium: permitted Web Share file types (FILE_TYPES.md)
- Chrome Platform Status: Web Share API and web-share permission policy
- Microsoft Edge: share content with other apps
- caniuse: Web Share API
-
MDN's compatibility data marks Edge 81 to 92 as "only supported on Windows" and full support from Edge 93. Chromium itself has no Linux implementation. ↩