Skip to content

fix: keep the root visible in ariaHideOutside when a target is in a shadow root - #735

Open
Nandan108 wants to merge 1 commit into
kobaltedev:mainfrom
Nandan108:fix/hide-outside-shadow-dom
Open

Nandan108 wants to merge 1 commit into
kobaltedev:mainfrom
Nandan108:fix/hide-outside-shadow-dom

Conversation

@Nandan108

Copy link
Copy Markdown
Contributor

Problem

Follow-up to #722, same shadow-boundary blind spot in a different primitive.

ariaHideOutside walks the page from root (document.body by default). It skips any node that contains one of its targets and hides the rest. The containment test was Node.contains, which does not cross shadow boundaries, so for a layer portalled into a shadow root:

body > main                  ← should be hidden
body > host{shadow} > layer  ← the open layer (target)

body.contains(layer) is false. The walk accepts root itself and sets aria-hidden="true" on <body>, which hides the open layer along with everything else.

This affects every modal layer that uses createHideOutside (menus and dialogs by default; popover, select and combobox when modal) whose Portal mounts into a shadow root.

In practice, browsers ignore aria-hidden on <body>, so screen-reader users aren't locked out. What they lose is the modal isolation: the page behind the layer is never hidden. I checked this in Firefox 155 by reading its accessibility tree over AT-SPI, which is what Orca consumes. Every element stays exposed with aria-hidden on <body>, whether it's set statically or after load, while aria-hidden on an ordinary wrapper element prunes its subtree as expected. Chromium's tree (via CDP) shows the same. Safari/VoiceOver isn't tested.

Tooling that implements the attribute literally does break. Playwright's getByRole finds nothing inside the open menu.

Fix

Use containsComposed (added in #722 for createInteractOutside) for both containment checks in ariaHideOutside:

  • The tree walk. This is the actual fix. The shadow host and its ancestors are now skipped, and the page beside them is hidden as intended.
  • The MutationObserver's check, for consistency. The observer never sees mutations inside shadow roots, so in the layouts tested this is behaviour-neutral.

For targets that aren't in a shadow root, containsComposed gives the same answer as contains, so this changes nothing outside shadow DOM.

I imported the helper from create-interact-outside/utils rather than moving it, to keep the diff small. Happy to move it somewhere shared if you prefer.

Tests

Two cases added to aria-hide-outside.test.tsx, in a Shadow DOM block:

Case Without the fix
target inside a shadow root: body and the host stay visible, the sibling main is hidden, revert restores it fails (body is hidden)
element added to body after the call is still hidden, body isn't fails (body is hidden)

The 11 existing ariaHideOutside tests are unchanged and pass.

Known limitation (unchanged by this PR)

The walk doesn't descend into shadow roots, which leaves two gaps. Neither is made worse by this change:

  • Stacked modal layers in the same shadow root. The lower layer stays exposed. In light DOM, each Portal wrapper div would be hidden.
  • A modal target inside a shadow root that also holds content to hide, e.g. a modal combobox whose control lives in a shadow-rooted app. The host gets skipped, so nothing inside it is hidden.

Handling those properly means recursing into open shadow roots and observing each of them, which felt like more than a bug fix should take on.

Verification

  • create-hide-outside suite: 13/13 pass; both new tests fail without the fix.
  • biome check clean, pnpm -F @kobalte/core build succeeds.
  • The full @kobalte/core suite shows the same intermittent toggle-group / toast / tabs failures on unmodified main as with this change; the only difference is +2 passing tests.
  • Also checked in jsdom against a replica of a real app's layout (app rendered in one shadow root, overlays portalled into a second): <body> stays visible and the page wrapper is hidden. That app currently shows aria-hidden on <body> in Chromium on 0.13.14.

The same pattern exists in @solid-primitives/interaction's ariaHideOutside, which Kobalte's solid2 branch uses. I mentioned it in solidjs-community/solid-primitives#1056 and can port this there once that PR lands.

🤖 Generated with Claude Code

…hadow root

`ariaHideOutside` skips any node that contains one of its targets and hides
the rest. It tested that with `Node.contains`, which does not cross shadow
boundaries: for a layer portalled into a shadow root, `body.contains(layer)`
is false. The walk therefore accepted `root` itself and set `aria-hidden` on
`<body>`, hiding the open layer along with the page. Chromium ignores
`aria-hidden` on `<body>`, which masks it by hand; accessibility-tree
tooling such as Playwright's `getByRole` does not.

Use `containsComposed` (added for createInteractOutside in kobaltedev#722) for both
containment checks: the tree walk, where it fixes the bug, and the
MutationObserver's, for consistency. With it, the shadow host's ancestors
are skipped and the page beside them is hidden, as intended.

The walk still does not descend into shadow roots, so siblings of a target
inside the same shadow root are left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@netlify

netlify Bot commented Sep 19, 2026

Copy link
Copy Markdown

👷 Deploy request for kobalte pending review.

Visit the deploys page to approve it

Name Link
🔨 Latest commit cc65c12

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant