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