What does a modal dialog need to be accessible? Walk through the focus trap, aria-modal, and restoring focus.
The full modal contract: role, labelling, focus trap, Escape, background inert, and focus restoration.
An accessible modal has a precise contract. (1) SEMANTICS: role="dialog" (or role="alertdialog" for a confirm/error that demands a response) plus aria-modal="true", and a name via aria-labelledby pointing at the dialog title (aria-describedby for the body if useful). (2) FOCUS IN: when it opens, move focus INTO the dialog — to the first focusable control, or the dialog container/heading if there isn't an obvious one. (3) FOCUS TRAP: while open, Tab and Shift+Tab must cycle only within the dialog; Tab from the last element wraps to the first and vice versa, so focus can't escape to the page behind. (4) BACKGROUND INERT: content behind the dialog must be unreachable AND unread — set inert (or aria-hidden="true") on the rest of the page so screen-reader virtual-cursor browsing and Tab both stay contained; aria-modal helps but the inert background is what actually prevents 'reading behind'. (5) ESCAPE + dismiss: Escape closes it; clicking the backdrop typically closes (but not for alertdialog). (6) FOCUS RESTORATION: on close, return focus to the element that opened it (store it on open) — otherwise the user is dumped at the top of the document. The modern shortcut is the native <dialog> element with showModal(), which gives you the top layer, a real backdrop (::backdrop), Escape-to-close, and background inertness for free — you still supply the label and focus restoration, but it removes most of the trap boilerplate and its bugs. Rolling your own is where teams get it wrong: forgetting the trap, forgetting to restore focus, or hiding the background from sighted users (CSS) but not from AT.
Building any modal/overlay; auditing one for trap, inert background, Escape, and focus restore.
function Modal({ open, onClose, title, children }) {
const ref = React.useRef(null);
const opener = React.useRef(null);
React.useEffect(() => {
if (!open) return;
opener.current = document.activeElement; // remember what to restore
const dlg = ref.current;
dlg.showModal(); // native: top layer + backdrop + inert bg + Esc
const first = dlg.querySelector('button, [href], input, select, textarea, [tabindex]');
(first || dlg).focus();
return () => { dlg.close(); opener.current && opener.current.focus(); }; // restore focus on close
}, [open]);
return (
<dialog ref={ref} aria-labelledby="dlg-title" onCancel={onClose} onClose={onClose}>
<h2 id="dlg-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</dialog>
);
}