Nested Focus Traps: Managing Multiple Modals with a Layer Stack
Nested Focus Traps: Managing Multiple Modals with a Layer Stack
When a dialog opens over another dialog, Escape should close only the topmost one. Tab should cycle only inside the topmost modal. This expectation is simple. The implementation is not.
WCAG 2.1 Criterion 2.1.2 (No Keyboard Trap): "If keyboard focus can be moved to a component, it must be possible to move focus away from that component using only a keyboard." For dialogs: Tab must not escape the modal, Escape must close the modal.
But what when a Dialog opens over another Dialog? A confirmation inside an alert? A popover over a sidebar? useFocusTrap handles this with a module-level stack in modules/ui/Overlays/shared/useFocusTrap.ts.
Module-Level layerStack
// Module-level — all useFocusTrap instances share the same stack
const layerStack: Array<React.RefObject<HTMLElement | null>> = [];
This array lives in the module's scope. Each useFocusTrap instance pushes its ref onto the stack when activated and removes it on cleanup. Last in, first out — the most recently opened overlay is at the top.
export function useFocusTrap(
ref: React.RefObject<HTMLElement | null>,
active: boolean,
) {
useEffect(() => {
if (!active) return;
layerStack.push(ref);
setTimeout(() => {
if (!isTopLayer(ref)) return;
const firstFocusable = getFirstFocusable(ref.current);
firstFocusable?.focus();
}, 0);
return () => {
const idx = layerStack.indexOf(ref);
if (idx !== -1) layerStack.splice(idx, 1);
};
}, [active, ref]);
layerStack.push(ref) — this trap is now active. Cleanup: layerStack.splice(idx, 1) — this trap closed, remove it from the stack.
setTimeout(..., 0) deferred focus: the DOM may not be fully rendered synchronously. One tick later, the modal is complete and focus can be safely moved.
isTopLayer() Check
function isTopLayer(ref: React.RefObject<HTMLElement | null>): boolean {
return layerStack[layerStack.length - 1] === ref;
}
The last element in the stack is the "active layer." Tab and Escape events are only handled by the top layer:
useEffect(() => {
if (!active) return;
function handleKeyDown(e: KeyboardEvent) {
if (!isTopLayer(ref)) return; // not the top layer — do nothing
if (e.key === 'Tab') {
e.preventDefault();
const focusables = getFocusableElements(ref.current);
if (!focusables.length) return;
const currentIndex = focusables.indexOf(document.activeElement as HTMLElement);
const nextIndex = e.shiftKey
? (currentIndex - 1 + focusables.length) % focusables.length
: (currentIndex + 1) % focusables.length;
focusables[nextIndex]?.focus();
}
if (e.key === 'Escape') {
if (isFocusTrapTopLayer(ref)) {
onEscape?.();
}
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [active, ref, onEscape]);
document.addEventListener — every useFocusTrap instance adds a listener to the document. But isTopLayer(ref) means only the top layer reacts. Lower layers see Tab and Escape but return early.
The isFocusTrapTopLayer() Export
export function isFocusTrapTopLayer(
ref: React.RefObject<HTMLElement | null>,
): boolean {
return layerStack[layerStack.length - 1] === ref;
}
This function is exported for sibling hooks. useDismiss (backdrop click to close) also listens for Escape. If useDismiss and useFocusTrap are both active on the same modal, Escape would fire twice.
// useDismiss.ts
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (!isFocusTrapTopLayer(ref)) return; // focus trap is higher — let it handle
onDismiss();
}
}
Both hooks ask the same question — "am I the top layer?" — from the same source of truth.
data-focus-guard Sentinel Filtering
function getFocusableElements(container: HTMLElement | null): HTMLElement[] {
if (!container) return [];
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
return Array.from(container.querySelectorAll<HTMLElement>(selector))
.filter((el) => !el.dataset.focusGuard);
}
Elements with data-focus-guard are excluded from the focusable list. These sentinels mark the trap's boundary:
export function FocusTrap({ active, onEscape, children }: FocusTrapProps) {
const ref = useRef<HTMLDivElement>(null);
useFocusTrap(ref, active, onEscape);
return (
<div ref={ref}>
<span data-focus-guard tabIndex={0} aria-hidden="true" />
{children}
<span data-focus-guard tabIndex={0} aria-hidden="true" />
</div>
);
}
Sentinels are focusable (for some boundary implementations) but filtered out of the getFocusableElements list, and aria-hidden from screen readers.
Scenario: Two Nested Dialogs
function ParentDialog({ open, onClose }: Props) {
const [confirmOpen, setConfirmOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useFocusTrap(ref, open);
return (
<dialog open={open}>
<div ref={ref}>
<button onClick={() => setConfirmOpen(true)}>Delete</button>
{confirmOpen && (
<ConfirmDialog
onConfirm={() => { /* delete */ onClose(); }}
onCancel={() => setConfirmOpen(false)}
/>
)}
</div>
</dialog>
);
}
function ConfirmDialog({ onConfirm, onCancel }: Props) {
const ref = useRef<HTMLDivElement>(null);
useFocusTrap(ref, true);
return (
<div role="alertdialog" ref={ref}>
<p>Are you sure?</p>
<button onClick={onConfirm}>Delete</button>
<button onClick={onCancel}>Cancel</button>
</div>
);
}
Sequence:
ParentDialogopens →layerStack: [parentRef]- "Delete" clicked,
ConfirmDialogopens →layerStack: [parentRef, confirmRef] - Tab:
isTopLayer(parentRef)= false, passes.isTopLayer(confirmRef)= true, cycles within ConfirmDialog - Escape:
isTopLayer(parentRef)= false,onEscapedoesn't fire.isTopLayer(confirmRef)= true →onCancelcalled, ConfirmDialog closes - ConfirmDialog unmounts →
layerStack: [parentRef] - Tab:
isTopLayer(parentRef)= true → ParentDialog Tab cycling resumes
The stack automatically routes focus to the right layer without any explicit re-activation.
Trade-off
Module-level array is global state. Tests need cleanup between cases:
afterEach(() => {
while (layerStack.length) layerStack.pop();
});
But layerStack isn't exported — tests can't access it directly. The workaround: set active = false in tests to trigger the cleanup effect. Isolation is incomplete.
document.addEventListener('keydown', ...) adds one listener per active trap. Five active traps = five listeners. Not a problem in practice, but a leak risk if cleanup effects don't run. React StrictMode double-invocation catches this early.
ARIA Role Integration
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
aria-modal="true" tells modern screen readers to ignore content outside the modal. Focus trap keeps the keyboard inside; aria-modal keeps the screen reader virtual cursor inside. For older screen readers without aria-modal support, background content should be aria-hidden="true".
Business Impact
Keyboard-only users — people with motor disabilities, power users, VI users — encounter the most problems at modals: pressing Tab inside an open modal and having focus escape to the background page.
For banking, healthcare, and government web applications, this is a legal accessibility requirement. A Confirm Dialog where Tab escapes to the document is simultaneously an accessibility violation and a UX failure.
Something to Try
Test your modal: open it, press Tab, and check whether focus escapes. If you've never tested this before, it probably escapes.
Minimum focus trap:
useEffect(() => {
if (!active) return;
const handleTab = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusables = Array.from(
ref.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) ?? []
);
if (!focusables.length) return;
const first = focusables[0], last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus();
}
};
document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
}, [active]);
This handles Tab wraparound at the boundaries. Add the layerStack pattern when you need nested modal support.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox