Skip to content

Dialog / AlertDialog: mark background content inert while a modal is open #298

Description

@sagikazarmark

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:

  1. 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.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions