Building an Accessible Share Modal with a Focus Trap

This guide is part of Building a Custom Share Sheet, which itself is part of Permission Flows and Progressive Enhancement.

Most custom share modals are inaccessible in the same four ways: Tab escapes into the page behind them, Escape does nothing, the dialog has no accessible name, and closing it strands focus at the top of the document. All four disappear when the modal is a real <dialog> opened with showModal() — the platform supplies the containment, the Escape handling and the inertness. What remains is small and worth doing carefully.

Feature Detection Gate

<dialog> is broadly supported, but a graceful non-modal path costs three lines and covers old engines.

// dialog-support.js
export function supportsModalDialog() {
  return typeof HTMLDialogElement === 'function' &&
    typeof HTMLDialogElement.prototype.showModal === 'function';
}

export function openDialog(dialog) {
  if (supportsModalDialog()) {
    dialog.showModal();
    return 'modal';
  }
  dialog.setAttribute('open', '');       // visible, but not modal
  return 'inline';
}

Knowing which mode you are in matters, because in the inline case you are responsible for the behaviours showModal would otherwise have given you.

What the Platform Gives, What You Must Add Two columns. The dialog element supplies focus containment, Escape to close, background inertness and top-layer rendering. Application code must supply the accessible name, the initial focus, focus restoration on close, and a live region announcing the copy result. Free from showModal() Tab is contained inside the dialog Escape closes it — no key listener the page behind becomes inert top layer — no z-index fighting You must still write aria-labelledby → the heading focus the primary action on open return focus to the trigger on close live region for the copy result

Step 1 — Give the Dialog an Accessible Name and a Live Region

<dialog id="share-sheet" class="share-sheet" aria-labelledby="share-sheet-heading">
  <h2 id="share-sheet-heading">Share this article</h2>

  <ul class="share-targets">
    <li><button type="button" data-action="copy">Copy link</button></li>
    <li><a data-target="email" href="#">Email</a></li>
    <li><a data-target="whatsapp" href="#" target="_blank" rel="noopener">WhatsApp</a></li>
  </ul>

  <p class="share-status" role="status" aria-live="polite"></p>
  <button type="button" data-action="close">Close</button>
</dialog>

Three details do the work. aria-labelledby gives the dialog a name, so a screen reader announces “Share this article, dialog” instead of just “dialog”. role="status" creates a polite live region that announces the copy confirmation without stealing focus. And every control is a real button or a, so keyboard operation and role mapping come for free.

Do not add role="dialog" or aria-modal="true" by hand: both are implicit for a <dialog> opened with showModal, and setting them manually can conflict with the browser’s own bookkeeping.

Step 2 — Move Focus In, and Put It Back

This is the part the platform does not do, and the part users feel immediately.

// accessible-share-modal.js
import { openDialog } from './dialog-support.js';

export function createShareModal(dialog) {
  let trigger = null;

  dialog.addEventListener('close', () => {
    // Covers every close path: the button, Escape, and form method="dialog".
    trigger?.focus();
    trigger = null;
    dialog.querySelector('.share-status').textContent = '';
  });

  dialog.addEventListener('click', (event) => {
    if (event.target.closest('[data-action="close"]')) dialog.close();
    // Clicking the backdrop targets the dialog element itself.
    if (event.target === dialog) dialog.close();
  });

  return {
    open(triggerEl) {
      trigger = triggerEl;
      openDialog(dialog);
      dialog.querySelector('[data-action="copy"]')?.focus();
    }
  };
}

Restoring focus in the close event rather than in the close button handler is deliberate: it is the one place that catches every route out of the dialog, including Escape, which is exactly the path a hand-rolled handler forgets.

The backdrop check is a small trick worth knowing — a click on the backdrop has the <dialog> itself as its target, because the backdrop is a pseudo-element rather than a separate node.

Step 3 — Announce Results Instead of Only Showing Them

A copy confirmation that only changes pixels is invisible to a screen-reader user, who is left unsure whether the button worked.

// announce-copy.js
export async function copyAndAnnounce(url, statusEl) {
  try {
    if (window.isSecureContext && navigator.clipboard?.writeText) {
      await navigator.clipboard.writeText(url);
      statusEl.textContent = 'Link copied to clipboard';
      return true;
    }
    throw new Error('clipboard unavailable');
  } catch {
    statusEl.textContent = 'Copy failed — select the link and press Ctrl+C';
    return false;
  }
}

Write to textContent rather than swapping the whole element: replacing the node removes the live region and re-inserts a new one, and several screen readers will not announce content that arrives together with its container. Assigning text into an element that was already present when the dialog opened is the reliable pattern.

Focus Journey Through the Dialog Focus starts on the Share button, moves to the Copy link action when the dialog opens, cycles between the actions while it is open, and returns to the Share button when the dialog closes by any route, including Escape. Share button focus starts here open dialog (modal) Copy link ← focused on open Email Close Tab cycles here only — never behind close / Esc focus restored back on the trigger next Tab continues from where it was

Step 4 — Verify It the Way Users Experience It

Automated checks catch missing names and missing roles; they cannot tell you that focus went somewhere useless. Four manual passes cover what matters, and each takes under a minute.

Keyboard only. Put the mouse away. Tab to the Share control, press Enter, and confirm focus lands on Copy link rather than on the dialog container. Tab through the list and check it cycles rather than escaping into the page. Press Escape and confirm focus returns to the Share control, not to the top of the document.

Screen reader. With VoiceOver or NVDA running, open the dialog and listen for the name — “Share this article, dialog” — then activate Copy link and confirm the confirmation is spoken. Silence here means the live region is not working, usually because the status element was replaced rather than updated.

Zoom to 400%. At that magnification the dialog must still fit and scroll rather than clipping its actions off-screen. A fixed height is the usual culprit; max-height: 90dvh with an internal scroll region fixes it.

Windows high contrast. In forced-colors mode, confirm the dialog still has a visible boundary against the page behind it. Backgrounds set only through background-color are discarded in that mode, so a dialog with no border becomes indistinguishable from the page.

// dialog-a11y.test.js — the assertions worth automating
import { it, expect } from 'vitest';

it('names the dialog and focuses the primary action', () => {
  const dialog = document.querySelector('#share-sheet');
  const labelId = dialog.getAttribute('aria-labelledby');

  expect(labelId).toBeTruthy();
  expect(document.getElementById(labelId).textContent.trim()).not.toBe('');
  expect(dialog.querySelector('[role="status"]')).not.toBeNull();
  expect(dialog.querySelector('[data-action="copy"]')).not.toBeNull();
});

Failure Modes and Recovery

Symptom Cause Minimal fix
Tab reaches content behind the modal Opened with show() or open attribute Use showModal()
Escape does nothing Modal built on a div Use a real <dialog>
Announced only as “dialog” No accessible name aria-labelledby pointing at the heading
Focus lost after closing Nothing restores it Focus the stored trigger in the close handler
Copy result never announced Status text not in a live region role="status" on a pre-existing element
Live region silent despite the role Whole element replaced on update Assign to textContent instead
Modal renders under a sticky header Custom z-index stacking showModal() puts it in the top layer
Updating a Live Region So It Is Heard Assigning text into a status element that was already in the document is announced. Replacing the element itself, or inserting the region together with its content, frequently is not, because the region was not present when the change occurred. statusEl.textContent = 'Link copied' region already in the DOM · reliably announced container.innerHTML = '<p role="status">Link copied</p>' region and content arrive together · often silent Rule: render the empty live region when the dialog is built, then write text into it later

Browser and Platform Caveat

<dialog> and showModal() are available in current Chrome, Edge, Safari and Firefox, and their focus and Escape behaviour is consistent enough to rely on. Two differences are worth knowing. Backdrop clicks are not closed automatically by any browser — the handler above is required if you want that behaviour, and it is worth pairing with a visible Close button since backdrop dismissal is undiscoverable. And on iOS, opening a modal while the on-screen keyboard is visible can leave the viewport scrolled oddly; focusing a button rather than a text input on open avoids it entirely, which the ordering above already does. For the keyboard specifics of the target list itself, see keyboard navigation for custom share menus.

FAQ

Do I still need a JavaScript focus trap with the dialog element?

No. showModal already contains Tab within the dialog and makes the rest of the document inert, so a hand-written trap adds risk without adding behaviour. What you do still have to write is the initial focus on open and the restoration of focus on close.

Why does focus jump to the top of the page after closing?

Because nothing restored it. When a dialog closes, focus falls back to the document body unless you move it, so the next Tab starts from the beginning. Remember the element that opened the dialog and focus it again in the close handler.

Does the dialog need role=dialog and aria-modal?

Not when you use a real <dialog> element opened with showModal — the role and modality are implicit, and adding aria-modal manually can conflict with the browser’s own handling. What it does need is an accessible name, via aria-labelledby pointing at the heading.