Nested Dialogs and Stacked Focus Traps

Permalink to "Nested Dialogs and Stacked Focus Traps"

Data apps nest dialogs more than they should: an edit-record dialog opens a “choose customer” dialog, which opens a “create customer” dialog. Each layer needs its own focus trap, but only the top one should be active. Escape should close one layer, not all. And when a child closes, focus must return to the button inside the parent dialog that opened it — not to the page behind. Native <dialog> with showModal() handles much of this; custom implementations need an explicit stack.

This guide belongs to keyboard focus trapping and navigation.

Spec reference

Permalink to "Spec reference"
  • ARIA dialog (modal) pattern — focus contained within the active dialog; Escape closes it; focus returns to the invoker.
  • HTML dialog showModal() — the browser keeps a top layer stack and makes content outside the top modal inert.
  • SC 2.1.2 No Keyboard Trap (A) — users can always leave each layer.
  • SC 2.4.3 (A) — focus order follows the layers logically.
A stack of three modal dialogs Layers: the page at the bottom is inert; the edit record dialog above it is inert while a child is open; the choose customer dialog above that is inert; the create customer dialog at the top is the only active layer. A stack of three modal dialogsPageinertEdit invoice dialoginert while child openChoose customerdialoginert while child openCreate customerdialogactive: focus trapped here
Only the top layer receives focus and keys.

When to use — and when not to

Permalink to "When to use — and when not to"

Nest only when the child task is short and its result feeds the parent (pick or create a related record). Two levels is a practical maximum.

Avoid nesting when the child task is long or independent; turn the parent into a page or use a multi-step dialog. The misapplication: a global Escape handler that closes every open dialog at once, dumping users onto the page and discarding the parent’s unsaved form.

Flat global trap versus a dialog stack Comparison: a single global trap and Escape handler loses state and focus across layers; a stack with per-layer traps and returns keeps context. Flat global trap versus a dialog stack✗ One global focus trapEscape closes allFocus returns to pageParent form discarded✓ Trap per layer, top only activeEscape closes top onlyFocus returns to opener in parentParent state kept

Annotated code example

Permalink to "Annotated code example"
// Native: showModal handles the top layer and inertness
editDialog.showModal();
chooseBtn.addEventListener('click', () => customerDialog.showModal());   // stacks above
customerDialog.addEventListener('close', () => chooseBtn.focus());       // return inside parent
// Custom: an explicit stack
const stack = [];
function openDialog(dlg, opener) {
  stack.at(-1)?.el.setAttribute('inert', '');            // previous top becomes inert
  stack.push({ el: dlg, opener });
  dlg.hidden = false; dlg.querySelector('[autofocus], button, input')?.focus();
}
function closeTop() {
  const { el, opener } = stack.pop();
  el.hidden = true;
  stack.at(-1)?.el.removeAttribute('inert');             // parent active again
  opener.focus();                                        // return to the button that opened it
}
document.addEventListener('keydown', e => { if (e.key === 'Escape' && stack.length) { e.stopPropagation(); closeTop(); } });

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Action Focus Announcement
Open child dialog First field in child “Create customer, dialog”
Tab at end of child Wraps within child —
Escape in child Opener in parent “Choose customer, button”
Escape in parent Opener on page “Edit, button”
Save in child Parent field updated “Customer Lund & Co selected”
Creating a customer from inside an edit dialog Sequence: from the edit dialog the user opens the choose customer dialog, then the create customer dialog; on save the create dialog closes and focus returns to the choose dialog, which selects the new customer and closes, returning focus to the edit dialog's customer field. Creating a customer from inside an edit dialogEditChooseCreateStatus"Choose customer" opened"New customer" openedsave, close, focus "Newcustomer" buttonselect, close, focuscustomer field"Customer Lund & Co selected"
Each close returns focus one layer down.

Step-by-step implementation

Permalink to "Step-by-step implementation"
  1. Prefer native dialogs. showModal() maintains the top layer, makes everything below inert and handles Escape per dialog.
  2. Record openers. Store the element that opened each dialog; it may be inside another dialog.
  3. Stop Escape propagation. Handle Escape in the top dialog and stop it from reaching handlers in lower layers.
  4. Pass results down. When the child closes with a result, update the parent and announce the change.
  5. Protect unsaved parent state. Never close the parent when a child closes or is cancelled.

Integration context

Permalink to "Integration context"

Native versus custom traps are compared in native dialog versus custom focus traps. Inertness is covered in using inert to isolate background content and return focus in restoring focus after closing complex modals. Popovers inside dialogs follow focus trapping in data grid filter popovers.

Gotchas

Permalink to "Gotchas"

Third-party trap libraries. Some libraries keep one global trap; check they support stacking or pause and resume.

Popovers in dialogs. A combobox popup inside a dialog is not a nested dialog; it should not create a new trap.

Scroll locking. Lock body scroll once for the stack, not per dialog, or closing a child unlocks the page under the parent.

Edge cases

Permalink to "Edge cases"
  • Opener removed. If the child’s action removed its opener (for example, a list item), return focus to a stable element in the parent.
  • Confirmation dialogs. “Discard changes?” from a parent’s Cancel button is a nested dialog; return to Cancel on “Keep editing”.
  • Mobile. Full-screen dialogs on small screens make nesting even more disorienting; consider page navigation instead.

Worked example

Permalink to "Worked example"

An invoicing app opened “Choose customer” from the invoice dialog, and “New customer” from there. Its focus trap library supported one trap at a time, so opening the third layer released the second, and Escape closed everything, discarding the invoice. The team switched to native dialogs with showModal(), recorded openers and stopped Escape propagation. Users could create a customer mid-invoice and return to exactly where they were.

Common mistakes

Permalink to "Common mistakes"
  • Global Escape closing all layers.
  • Focus returning to the page instead of the parent.
  • Lower dialogs left interactive.
  • Three or more levels of nesting.

Testing checklist

Permalink to "Testing checklist"

Testing it with a screen reader

Permalink to "Testing it with a screen reader"

With NVDA, open the parent dialog and confirm its name is announced. Open the child and confirm the new dialog name and first field. Try to reach parent content with Tab and with browse-mode arrows; neither should escape the child. Press Escape and listen for the parent’s opener button. In VoiceOver, check that the rotor lists only the top dialog’s content. Finally, complete the child and confirm the parent announces the updated value.

Alternatives to nesting

Permalink to "Alternatives to nesting"

Before adding a second dialog, ask whether the child task can be inline. A “create customer” form can expand within the parent dialog as a disclosure, replacing the customer field until saved. A multi-step dialog can swap its content for the child task and offer “Back” to return. A side sheet next to a page-based editor avoids modality altogether. Each removes a layer of focus management and makes the flow easier for screen reader and magnifier users, who find stacked overlays especially hard to track.

Announcing results across layers

Permalink to "Announcing results across layers"

When a child dialog closes with a result, the parent changes in a way the user did not see happen. Announce the change in one short message (“Customer Lund & Co selected”) and move focus to the field that changed, not just to the opener, if that field is the natural next step. If the child was cancelled, return focus to the opener and say nothing; silence plus the familiar button name tells the user nothing changed.

Design system notes

Permalink to "Design system notes"

Provide a dialog service that manages the stack, openers and Escape handling, and document a two-level limit.

FAQ

Permalink to "FAQ"
Does native dialog support stacking?

Yes. Multiple modal dialogs stack in the top layer.

Should the parent be hidden when the child opens?

No. It stays visible but inert.

What about aria-modal?

Native modals imply it; for custom dialogs set it on the top dialog.

Can I nest a non-modal panel?

Yes, but it must not trap focus.

How do screen readers know which dialog is active?

Inertness removes lower layers from the accessibility tree.

What if a child needs a child?

Reconsider the design; three levels is too many for most users.

Permalink to "Related"

← Back to Keyboard Focus Trapping & Navigation for Data Interfaces