Problem
A modal Dialog traps Tab but leaves the rest of the page reachable. Nothing outside the
dialog is marked inert, so a screen reader's browse-mode virtual cursor, a pointer, or a
programmatic .focus() can still reach and read the content behind the modal.
AlertDialog has the same gap, and unconditionally — it has no is_modal gate because an alert
dialog is always modal.
Observed at main (bf007c15d0cf4d04d3181cc46cf12325aa773955).
Evidence
DialogContent sets the right roles (primitives/src/dialog.rs:256-262):
div {
id,
role: "dialog",
aria_modal: "true",
...
}
but the focus trap installed when is_modal (primitives/src/dialog.rs:231-253) is, in its
entirety, a Tab-keydown interceptor — primitives/src/js/focus-trap.js (deminified for
readability):
this.container.addEventListener("keydown", (event) => {
if (event.key === "Tab") {
if (event.shiftKey) this.focusPrevious(); else this.focusNext();
event.preventDefault();
}
});
grep -rn inert primitives/src/ returns nothing. The only aria_hidden in dialog.rs is at
:142, on the dialog root while it is closed — unrelated to background content.
alert_dialog.rs installs the same FOCUS_TRAP_JS (:103) and likewise never touches the
background.
So Tab is constrained and nothing else is.
Why aria-modal alone isn't enough
aria-modal="true" is the standards-blessed signal and it is already set, so this is a
robustness gap rather than a missing basic. Three reasons to pair it with inert:
- Support has been inconsistent enough across screen-reader / browser pairs that the WAI-ARIA
Authoring Practices and MDN both recommend marking background content inert (or
aria-hidden) in addition, rather than relying on aria-modal alone.
- It only addresses assistive tech. It does nothing about pointer interaction with background
controls, or about programmatic focus — an app calling element.focus() behind the modal, or
a background element that autofocuses on mount, moves focus straight out of the dialog.
- The trap can't recover. The listener binds to
this.container, so once focus leaves by any
route other than Tab, no Tab handler fires and focus is never brought back.
inert closes all three in one attribute: it removes a subtree from the accessibility tree and
makes it non-focusable and non-clickable.
Suggested shape
The walk. The dialog does not portal (no use_portal anywhere in dialog.rs), so it renders
inline wherever the caller mounts it. "Mark the overlay's siblings" is therefore not correct in
general — it only works when the overlay happens to sit at a known level. Walk from the dialog up
to <body>, marking each ancestor's other children:
let node = dialogEl;
while (node && node !== document.body) {
for (const sib of node.parentElement.children) if (sib !== node) mark(sib);
node = node.parentElement;
}
Per-instance markers, not a bare boolean. Two modal dialogs can be open at once — an
AlertDialog over a Dialog is the obvious case, and both live in this crate, so the fix should
compose across them by construction. With a single shared marker, closing the top modal would
strip inert from elements the one underneath still needs marked. Instead, each dialog writes its
own id into the marker (e.g. data-inert-by="<id>", space-separated when two dialogs mark the
same element); unwinding removes only that dialog's id and clears inert only when no ids
remain. This also protects inert the application set itself: an app-set inert carries no
marker, so it is never touched.
Bookkeeping.
- Gate on
is_modal for Dialog, matching the trap's own gate; unconditional for AlertDialog.
- Unwind on close and on unmount — a dialog can leave the document without a normal close.
- Content mounted behind an already-open modal is not marked until the dialog reopens, unless a
MutationObserver is carried for it; accepting and documenting that gap is a reasonable call,
since the modal's premise says it shouldn't happen.
- A prop to opt out is probably worth having for callers who manage
inert themselves.
Reference implementation
This is implemented downstream, in a registry that wraps these primitives:
dialog
and
alert_dialog
(INERT_JS plus a use_effect/use_drop pair on the open state), with Playwright coverage, and
tracked there in
sagikazarmark/dioxus-daisyui-components#9.
It is carried as a stopgap and will be dropped once the primitive grows the behaviour. Happy to
turn it into a PR here if the shape looks right.
Secondary, separable
FocusTrap.remove() is:
remove() { this.restoreFocusElement.focus() }
restoreFocusElement is captured as document.activeElement at construction. If the opener was
unmounted while the dialog was open — routine when the dialog's action re-renders the view behind
it — this focuses a detached node and focus falls to <body>, losing keyboard position. A
liveness check with a fallback fixes it:
remove() {
if (this.restoreFocusElement?.isConnected) this.restoreFocusElement.focus();
else document.querySelector("main")?.focus();
}
Happy to split this into its own issue if that's preferred.
Problem
A modal
Dialogtraps Tab but leaves the rest of the page reachable. Nothing outside thedialog is marked
inert, so a screen reader's browse-mode virtual cursor, a pointer, or aprogrammatic
.focus()can still reach and read the content behind the modal.AlertDialoghas the same gap, and unconditionally — it has nois_modalgate because an alertdialog is always modal.
Observed at
main(bf007c15d0cf4d04d3181cc46cf12325aa773955).Evidence
DialogContentsets the right roles (primitives/src/dialog.rs:256-262):but the focus trap installed when
is_modal(primitives/src/dialog.rs:231-253) is, in itsentirety, a Tab-keydown interceptor —
primitives/src/js/focus-trap.js(deminified forreadability):
grep -rn inert primitives/src/returns nothing. The onlyaria_hiddenindialog.rsis at:142, on the dialog root while it is closed — unrelated to background content.alert_dialog.rsinstalls the sameFOCUS_TRAP_JS(:103) and likewise never touches thebackground.
So
Tabis constrained and nothing else is.Why
aria-modalalone isn't enougharia-modal="true"is the standards-blessed signal and it is already set, so this is arobustness gap rather than a missing basic. Three reasons to pair it with
inert:Authoring Practices and MDN both recommend marking background content
inert(oraria-hidden) in addition, rather than relying onaria-modalalone.controls, or about programmatic focus — an app calling
element.focus()behind the modal, ora background element that autofocuses on mount, moves focus straight out of the dialog.
this.container, so once focus leaves by anyroute other than Tab, no Tab handler fires and focus is never brought back.
inertcloses all three in one attribute: it removes a subtree from the accessibility tree andmakes it non-focusable and non-clickable.
Suggested shape
The walk. The dialog does not portal (no
use_portalanywhere indialog.rs), so it rendersinline wherever the caller mounts it. "Mark the overlay's siblings" is therefore not correct in
general — it only works when the overlay happens to sit at a known level. Walk from the dialog up
to
<body>, marking each ancestor's other children:Per-instance markers, not a bare boolean. Two modal dialogs can be open at once — an
AlertDialogover aDialogis the obvious case, and both live in this crate, so the fix shouldcompose across them by construction. With a single shared marker, closing the top modal would
strip
inertfrom elements the one underneath still needs marked. Instead, each dialog writes itsown id into the marker (e.g.
data-inert-by="<id>", space-separated when two dialogs mark thesame element); unwinding removes only that dialog's id and clears
inertonly when no idsremain. This also protects
inertthe application set itself: an app-setinertcarries nomarker, so it is never touched.
Bookkeeping.
is_modalforDialog, matching the trap's own gate; unconditional forAlertDialog.MutationObserveris carried for it; accepting and documenting that gap is a reasonable call,since the modal's premise says it shouldn't happen.
inertthemselves.Reference implementation
This is implemented downstream, in a registry that wraps these primitives:
dialog
and
alert_dialog
(
INERT_JSplus ause_effect/use_droppair on the open state), with Playwright coverage, andtracked there in
sagikazarmark/dioxus-daisyui-components#9.
It is carried as a stopgap and will be dropped once the primitive grows the behaviour. Happy to
turn it into a PR here if the shape looks right.
Secondary, separable
FocusTrap.remove()is:restoreFocusElementis captured asdocument.activeElementat construction. If the opener wasunmounted while the dialog was open — routine when the dialog's action re-renders the view behind
it — this focuses a detached node and focus falls to
<body>, losing keyboard position. Aliveness check with a fallback fixes it:
Happy to split this into its own issue if that's preferred.