Why does my modal focus trap confuse screen readers after an async render?

Hey folks, I’m wiring up a modal in a React-ish app and trying to keep it accessible, but after an async state update the focus trap seems to fight with VoiceOver/NVDA and sometimes announces the page behind the dialog (or focus jumps to the close button twice).

function openModal() {
  setIsOpen(true);
  // content inside modal loads async, so height + focusables change
  requestAnimationFrame(() => {
    const dialog = document.querySelector('[role="dialog"]');
    dialog?.focus();
  });
}

document.addEventListener('focusin', (e) => {
  if (!isOpen) return;
  const dialog = document.querySelector('[role="dialog"]');
  if (dialog && !dialog.contains(e.target)) {
    // bounce focus back in
    dialog.focus();
  }
});

What’s the most reliable pattern to keep focus inside a dynamically-rendered modal without causing screen readers to announce background content or “double focus” jumps?

1 Like

That document-level focusin “bounce” is the culprit — you’re basically arm-wrestling the screen reader. VoiceOver/NVDA will move focus during async DOM changes / virtual cursor updates, and your handler yanks it back, so you get the double-hit on the close button and occasional announcements from behind the dialog while things are still settling.

The pattern that’s been the least flaky for me is to stop policing focus and instead make the background unfocusable. When the modal opens, put inert on the rest of the app (and use aria-hidden as a fallback for browsers that don’t support inert), then set focus exactly once to a stable element inside the modal (close button or first input) after the async content has actually rendered. Once the page behind literally can’t take focus, you can delete the global focusin listener and the “tug-of-war” mostly disappears.

1 Like