Accessibility · ARIA Practice

Modal Inertness: What a Dialog Must Remove From the Rest of the Page

A dialog is modal only if the rest of the document is unreachable by keyboard, pointer, and the accessibility tree. The containment mechanics, and the focus restoration that fails when the trigger is gone.

A dialog is modal when the rest of the document cannot be reached, and modality is not a visual property. A panel centred over a dimmed backdrop looks modal and is not: the background links are still in the tab sequence, still clickable through gaps in the overlay, and still present in the accessibility tree, so a screen reader user who browses by heading reads the page behind the dialog and has no indication that a dialog exists. Building modality means removing the rest of the document from three separate systems and then putting it back exactly as it was.

The dialog element, and what it already does

<dialog> with showModal() performs most of the work. It promotes the element to the top layer, which paints it above everything regardless of stacking context, so no z-index computation is needed. It makes the rest of the document inert, so that every element outside the dialog and its ancestors is removed from the tab sequence, from hit testing, and from the accessibility tree for the duration. It moves focus into the dialog, to the first focusable descendant or to the dialog itself if it has a tabindex. And it handles Escape by firing a cancel event and closing.

The remaining work is the parts the element does not do: the accessible name, the initial focus choice, the focus trap when content inside the dialog is itself removed, and restoration.

The name is required. A dialog without an accessible name is announced as a dialog with nothing, and a reader may not even announce the opening. aria-labelledby pointing at the dialog’s own heading is the conventional source, and the heading should be a real heading element so that heading navigation inside the dialog works.

<dialog id="confirm" aria-labelledby="confirm-title" aria-describedby="confirm-body">
  <h2 id="confirm-title">Delete this branch</h2>
  <p id="confirm-body">The commits will remain reachable by hash.</p>
  <button type="button" data-action="cancel">Cancel</button>
  <button type="button" data-action="delete">Delete</button>
</dialog>

The aria-describedby is worth including when the dialog has a short explanatory body: it causes the description to be read once on opening, which is usually the only chance the user gets to hear the consequence before choosing a button. On a dialog with a long body, describing it means reading the whole thing at open, which delays the user; there, let the body be navigable content and drop the description.

Containment without the top layer

A dialog implemented as a div needs the inertness applied by hand. The usual approach is to mark every sibling of the dialog’s ancestor chain as inert, or to wrap the page in a container and mark that container inert.

inert is the correct primitive because it removes the subtree from hit testing, from the focus sequence, and from the accessibility tree in one declaration, with no per-element bookkeeping. The alternative, writing aria-hidden="true" on the background, removes it only from the tree: the background remains focusable and clickable, so Tab still moves through it and the focus trap must fight the browser for every keystroke. The distinction between the layers is the subject of hiding subtrees without breaking focus, and it is the single most consequential choice in this pattern.

Applying inert to the background has an ordering requirement that is easy to miss. The dialog must not be a descendant of the container being made inert. If the dialog lives inside the application root along with everything else, marking the root inert also marks the dialog inert, and the dialog becomes unreachable. That failure looks like the dialog never opened, because focus cannot move into it. The dialog must be a sibling of the inert container, or the container must be split so that only the non-dialog part is inert.

Restoring is the reverse operation, and it must restore exactly the previous state rather than blanket-removing the attribute. A page may already contain inert regions for its own reasons, and clearing inert from everything on close makes previously unreachable content reachable. The bookkeeping is a set of elements the dialog added the attribute to, and the close handler removes it from those and only those.

The focus trap and its failure mode

Containment by inert makes the browser enforce the boundary: Tab from the last focusable element in the dialog has nowhere to go, so focus wraps to the browser’s own UI and then back into the dialog, which is acceptable and requires no code. A manual trap intercepts Tab at the boundary and calls focus() on the first or last element.

The manual trap exists for environments without inert support and for one case where inert is not enough: content inside the dialog that is removed while focused. When the focused button is deleted, as when a list row is removed or a step advanced, focus falls to the document body, which is outside the dialog, and the trap’s next Tab press computes from the wrong starting point. Handling it requires observing removals inside the dialog and moving focus to a defined element when document.activeElement is no longer contained.

The manual trap’s other weakness is that it enumerates focusable elements, and the enumeration is a guess. querySelectorAll on a selector list of focusable tags misses elements with tabindex, misses contenteditable, and includes elements hidden by display: none unless the selector excludes them. Every enumeration bug produces a trap that either lets focus escape or refuses to move to a legitimate element. Using inert and letting the browser handle the boundary removes the enumeration entirely, which is why the primitive is worth its browser requirement.

Choosing the initial focus

Focus must move into the dialog when it opens, and where it goes determines what the user hears first.

Focusing the first focusable element is the common default and is wrong when that element is a close button, because the user hears “Close” before hearing what the dialog is. Focusing the dialog element itself, by giving it tabindex="-1", makes the reader announce the dialog’s name and description, and the first Tab press then moves to the first control. That is the better default for dialogs whose content must be understood before action.

The exception is a dialog that is a form with a single obvious input, such as a rename dialog: focusing the input saves a keystroke and the label supplies the context. The other exception is a destructive confirmation, where focusing the destructive button by default makes an accidental Enter catastrophic; the safe default there is the cancel action or the dialog itself.

Focusing the dialog rather than a control also avoids a specific defect: a dialog whose first control is inside a scrollable body receives focus, the browser scrolls it into view, and the heading scrolls out of view before the user has read it. Focusing the dialog’s own box does not scroll its content.

Restoration, and the trigger that no longer exists

On close, focus must return to the element that opened the dialog. Without restoration, focus is on the document body and the user’s position in the page is lost; with a reader in document mode, the reading position is also lost.

The restoration target is the element that had focus at open time, captured before the dialog takes it. The capture must happen at open, not at close, because by close time the original element may have been removed or may have lost focus to something else.

The failure case is a trigger that no longer exists. A dialog opened from a row’s menu button, where the action deletes the row, closes with a trigger that has been unmounted. Calling focus() on a detached node does nothing and focus remains on the body. The correct behaviour is a fallback chain: the original trigger if it is still in the document, otherwise the nearest surviving ancestor or the container that held the trigger, otherwise a stable landmark. This is a design decision the dialog cannot make alone, because only the caller knows what replaced the trigger.

A related failure is the trigger that is now inert or hidden. Restoring focus to an element inside a region that the application has since collapsed produces a focus call that silently fails, or that scrolls a hidden region into view. Checking that the target is still connected, still visible, and not inert before focusing is the guard.

The backdrop, the scroll lock, and the escape hatch

The visual backdrop is not a semantic element. A div covering the viewport with a click handler that closes the dialog should carry aria-hidden="true", because it has no content and its only role is pointer capture. Without the attribute it is an unlabelled generic container in the tree, which is harmless but noise.

Locking the page scroll is a visual concern with an accessibility consequence: when the body’s overflow is set to hidden, the scroll position is preserved but the scrollbar disappears, and the layout shifts horizontally by the scrollbar’s width. On a page where the dialog is anchored to a control, that shift moves the control the dialog is meant to relate to. Reserving the scrollbar’s width with padding is the usual fix, and the width is not a fixed number across platforms and zoom levels, so it must be measured rather than assumed.

Escape must close the dialog. A dialog that traps focus and cannot be dismissed leaves a keyboard user with no exit except reloading the page, and this is the one failure in the pattern that has no recovery. The cancel event from <dialog> provides it; a manual implementation must bind Escape at the document level while the dialog is open and unbind on close, and must not close when Escape is pressed inside a nested popup that handles the key itself. The innermost handler should call stopPropagation, or the outer handler should check whether a descendant consumed the event; the combobox case, where the input and its list both react to Escape, is the usual source of this conflict, as described in the combobox popup contract.

Nested dialogs are the case where the accounting gets hard: two levels of inertness, two sets of restoration targets, and an Escape handler that must close only the top one. The stack discipline of pushing on open, popping on close, and restoring from the top of the stack is the only arrangement that survives three levels.

References

  1. WAI-ARIA 1.2: dialog role — W3C
  2. ARIA Authoring Practices Guide: Dialog Pattern — W3C WAI
  3. HTML Living Standard: The dialog element — WHATWG
  4. HTML Living Standard: The inert attribute — WHATWG