ariaHideOutside defaults to the module-global document, so modals in a second window hide the opener's <body>

Author: dieison-depraCreated Sep 7, 2026Updated Sep 7, 2026

Hello, and thank you

First, a genuine thank you. ariaHideOutside is one of those pieces that quietly does a lot of hard work, and the fact that it already handles shadow DOM, inert, and ref-counting is why our multi-window workspace got as far as it did before we hit an edge. We're reporting this because we'd like to keep using React Aria for it, not because we're stuck without a workaround.

What we're building: a workspace shell where the operator can detach a panel into a secondary browser window (window.open(), same origin). The detached panel's React subtree is rendered into the child window's document with createPortal, and RAC overlays inside it are pointed at the child window via UNSTABLE_portalContainer. That part works beautifully — menus, popovers and modals all open in the window the operator clicked in.

What we observe

When a RAC <ModalOverlay> / <Modal> opens inside that secondary window, aria-hidden="true" is set on the opener window's <body>, and Chrome logs:

Blocked aria-hidden on a <body> element because it would hide the entire accessibility
tree from assistive technology users.

We measured both documents at the moment the modal opens:

json
{
  "modalIsInChildDocument":  true,    // the portal works as intended
  "childBodyAriaHidden":     null,    // the child window is untouched
  "openerBodyAriaHidden":    "true",  // the opener's <body> is what got marked
  "openerHiddenChildren":    0
}

What we expected

ariaHideOutside would hide content in the document that actually contains the modal (the child window), leaving the opener's document alone.

Where it comes from

We traced it, and we think the mechanism is small and self-contained. In ariaHideOutside:

javascript
function ariaHideOutside(targets, options) {
  let windowObj = getOwnerWindow(targets?.[0]);          // ← window derived from the target
  let opts = options instanceof windowObj.Element ? { root: options } : options;
  let root = opts?.root ?? document.body;                // ← but the default root uses the module-global `document`
  ...
  let walker = createShadowTreeWalker(getOwnerDocument(root), root, ...);  // ← walker uses root's owner document
}

The function already derives windowObj from the target on the first line, and the tree walker already uses getOwnerDocument(root). Only the default root falls back to the module-scope document.

That global is the opener's document, because a window.open()ed same-origin window shares the opener's JavaScript realm: there are two documents but only one document global. So when the target lives in the child document, the walker starts from the opener's <body>, finds no target inside it, and ends up marking that <body> itself.

Call sites that hit this (neither passes root):

  • useModalOverlay: ariaHideOutside([ref.current], { shouldUseInert: true })
  • usePopover: ariaHideOutside([groupRef?.current ?? popoverRef.current], { shouldUseInert: true })

Why the existing escape hatches don't reach it

We looked for a supported way to express this before filing:

  • UNSTABLE_portalContainer (RAC) / UNSAFE_PortalProvider (@react-aria/overlays) correctly redirect where the overlay renders, but neither is consulted by ariaHideOutside — we confirmed there is no portal-context lookup in ariaHideOutside or useModalOverlay.
  • ariaHideOutside accepts a root, but ModalOverlay / Popover don't surface a way to pass one through.

So today the behavior isn't reachable from public API, which is why we're bringing it here rather than working around it locally.

Reproduction

Minimal shape (same origin, no iframe):

javascript
function DetachedPanel() {
  const [win, setWin] = useState(null);
  useEffect(() => { setWin(window.open('', '_blank', 'width=800,height=600')); }, []);
  if (!win) return null;

  return createPortal(
    <ModalOverlay isOpen UNSTABLE_portalContainer={win.document.body}>
      <Modal UNSTABLE_portalContainer={win.document.body}>
        <Dialog><Heading slot="title">Hello</Heading></Dialog>
      </Modal>
    </ModalOverlay>,
    win.document.body
  );
}

Then inspect from the child window:

javascript
window.opener.document.body.getAttribute('aria-hidden'); // "true"
document.body.getAttribute('aria-hidden');               // null

(Styles need to be copied into the child document for the modal to be visible, but the aria-hidden behavior does not depend on that.)

Impact

Chrome refuses aria-hidden on <body>, so the visible symptom is the console warning — but the intended effect is also lost: inside the child window, a screen reader still reaches the content behind the modal, so the dialog isn't modal for assistive technology. In an engine that doesn't block the attribute, the opener window would disappear from the accessibility tree entirely while a dialog is open somewhere else.

Proposed change

The smallest version we can see is one line, and it's consistent with what the function already does on lines above and below it:

diff
- let root = opts?.root ?? document.body;
+ let root = opts?.root ?? windowObj.document.body;

windowObj is already getOwnerWindow(targets?.[0]), which resolves to getOwnerDocument(target)?.defaultView ?? window. In a single-window app that is the same object as the module-global window, so windowObj.document.body is byte-for-byte today's value — we'd expect it to be behavior-preserving there. And it introduces no new failure mode when windowObj is undefined, since the line just above it (options instanceof windowObj.Element) already relies on it being defined.

If you'd rather not change a default, a narrower alternative that would also unblock us is threading an optional root (or UNSTABLE_ariaHideRoot) through useModalOverlay / usePopover and out to the RAC Modal / Popover props, so applications can be explicit.

We'd be glad to open a PR for whichever direction you prefer, including tests for the two-document case — just let us know which shape fits the codebase better.

Environment

  • react-aria 3.51.0, react-aria-components 1.20.0
  • React 19
  • Chromium 151, macOS
  • No iframe involved; same-origin window.open()

Related in spirit (same "secondary document" family, different function): #7743 — Support root element in usePreventScroll.

Thanks again for reading this far, and for the work on this library.