Skip to content

Nested Focus Traps: Managing Multiple Modals with a Layer Stack

3/24/2026Mobile DevelopmentKUIreact7 min read

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:

  1. ParentDialog opens → layerStack: [parentRef]
  2. "Delete" clicked, ConfirmDialog opens → layerStack: [parentRef, confirmRef]
  3. Tab: isTopLayer(parentRef) = false, passes. isTopLayer(confirmRef) = true, cycles within ConfirmDialog
  4. Escape: isTopLayer(parentRef) = false, onEscape doesn't fire. isTopLayer(confirmRef) = true → onCancel called, ConfirmDialog closes
  5. ConfirmDialog unmounts → layerStack: [parentRef]
  6. 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 Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox