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.

Native Sheet, Custom Sheet, and the Common Floor A single share intent is evaluated at render time. Where the Web Share API exists the native sheet is used and can carry files. Where it does not, a custom dialog offers destination links. Both routes rest on a copy-link action that is always available. share intent evaluated at render navigator.share available? yes Native share sheet real installed apps · can carry files zero maintenance — always prefer it no Custom dialog destination links you choose · no files focus-trapped, keyboard operable Copy link — the floor under both routes

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.

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.

Anatomy of the Dialog A share dialog with a heading, a focused copy-link button first in the list, three destination links, and a close button. Annotations mark the behaviours the dialog element supplies: focus containment, Escape to close, inert background, and focus returned to the opener. Share this page Copy link — focused on open Email WhatsApp LinkedIn Close Supplied by <dialog>.showModal() · Tab cycles inside the dialog only · Escape closes it, no listener needed · page behind becomes inert · rendered in the top layer, above z-index You add: focus the first action on open, and return focus to the opener on close.

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.

Ordering the Sheet The first item receives focus on open and is the one a hurried user taps, so it should be the action that always works. Copy link needs no app, account or permission; messaging destinations follow, and any that can dead-end sit last. 1 · Copy link — focused on open no app, no account, no permission · works on every platform 2 · the destinations your audience actually uses three or four, chosen from outbound-click data rather than from a logo grid 3 · anything that can dead-end mailto: with no client configured, or a web client the user is not signed into

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.