Issue
Setup:
- Node version: v22.x / Chrome 131+
- Virtual Screen Reader version: 0.32.1
- Environment: Playwright (browser build via
index.browser.js)
Details
The accessibility tree builder in createAccessibilityTree.ts does not enter open shadow roots. When used on web components with shadow DOM, the virtual screen reader sees only the host element — none of the shadow DOM content (landmarks, buttons, links, headings) is included in the spoken phrase log.
Reproduction
Given a page with a custom element using open shadow DOM:
<my-nav>
#shadow-root (open)
<nav aria-label="Main">
<button>Menu</button>
<a href="/about">About</a>
</nav>
</my-nav>
import { virtual } from "@guidepup/virtual-screen-reader";
await virtual.start({ container: document.body });
while ((await virtual.lastSpokenPhrase()) !== "end of document") {
await virtual.next();
}
console.log(await virtual.spokenPhraseLog());
// Actual: ["document", "document", "document", ...] (never reaches content)
// Expected: ["document", "navigation, Main", "button, Menu", "link, About", ...]
Root cause
growTree() at line 163 of src/createAccessibilityTree.ts iterates node.childNodes, which returns only light DOM children. Shadow DOM content lives in node.shadowRoot.childNodes and is never visited:
// Current: only light DOM
node.childNodes.forEach((childNode) => { ... });
// Missing: shadow DOM traversal
if (node instanceof HTMLElement && node.shadowRoot) {
node.shadowRoot.childNodes.forEach((childNode) => { ... });
}
The same issue affects:
querySelectorAll("[aria-flowto]") (line 76) — does not search inside shadow trees
querySelectorAll("[aria-owns]") (line 111) — same
getNodeByIdRef.ts:14 — container.querySelector() cannot find IDs inside shadow trees
observeDOM.ts — MutationObserver with subtree: true does not cross shadow boundaries
Real-world impact
This affects any project using web components with shadow DOM — which is increasingly common (Lit, Stencil, Shoelace, Spectrum Web Components, and frameworks like Angular that optionally use shadow DOM). We discovered this while trying to add screen reader tests for MeteoSwiss which uses nested custom elements with 3 levels of shadow DOM (host → button component → inner button).
Expected behavior per spec
The WAI-ARIA 1.2 spec defines the accessibility tree as the flattened (composed) tree. Shadow DOM is an encapsulation mechanism invisible to assistive technology. Real screen readers (VoiceOver, NVDA, JAWS) traverse the composed/flattened tree and correctly announce content inside shadow roots.
Since virtual-screen-reader targets ACCNAME 1.2, CORE-AAM 1.2, and WAI-ARIA 1.2 compliance, it should also traverse the flattened tree — entering open shadow roots and resolving <slot> elements via assignedNodes({ flatten: true }).
Proposed fix
The fix is scoped to a few locations:
-
growTree() — when a node has an open .shadowRoot, traverse its children (the shadow tree) instead of (or in addition to) the light DOM children. For <slot> elements, use slot.assignedNodes({ flatten: true }) to get the projected content.
-
querySelectorAll calls — use a recursive shadow-piercing query helper for aria-flowto and aria-owns resolution.
-
getNodeByIdRef — shadow-aware ID lookup.
-
observeDOM — observe each encountered shadow root separately.
I am happy to submit a PR with this fix if you are open to it.
Issue
Setup:
index.browser.js)Details
The accessibility tree builder in
createAccessibilityTree.tsdoes not enter open shadow roots. When used on web components with shadow DOM, the virtual screen reader sees only the host element — none of the shadow DOM content (landmarks, buttons, links, headings) is included in the spoken phrase log.Reproduction
Given a page with a custom element using open shadow DOM:
Root cause
growTree()at line 163 ofsrc/createAccessibilityTree.tsiteratesnode.childNodes, which returns only light DOM children. Shadow DOM content lives innode.shadowRoot.childNodesand is never visited:The same issue affects:
querySelectorAll("[aria-flowto]")(line 76) — does not search inside shadow treesquerySelectorAll("[aria-owns]")(line 111) — samegetNodeByIdRef.ts:14—container.querySelector()cannot find IDs inside shadow treesobserveDOM.ts— MutationObserver withsubtree: truedoes not cross shadow boundariesReal-world impact
This affects any project using web components with shadow DOM — which is increasingly common (Lit, Stencil, Shoelace, Spectrum Web Components, and frameworks like Angular that optionally use shadow DOM). We discovered this while trying to add screen reader tests for MeteoSwiss which uses nested custom elements with 3 levels of shadow DOM (host → button component → inner button).
Expected behavior per spec
The WAI-ARIA 1.2 spec defines the accessibility tree as the flattened (composed) tree. Shadow DOM is an encapsulation mechanism invisible to assistive technology. Real screen readers (VoiceOver, NVDA, JAWS) traverse the composed/flattened tree and correctly announce content inside shadow roots.
Since virtual-screen-reader targets ACCNAME 1.2, CORE-AAM 1.2, and WAI-ARIA 1.2 compliance, it should also traverse the flattened tree — entering open shadow roots and resolving
<slot>elements viaassignedNodes({ flatten: true }).Proposed fix
The fix is scoped to a few locations:
growTree()— when a node has an open.shadowRoot, traverse its children (the shadow tree) instead of (or in addition to) the light DOM children. For<slot>elements, useslot.assignedNodes({ flatten: true })to get the projected content.querySelectorAllcalls — use a recursive shadow-piercing query helper foraria-flowtoandaria-ownsresolution.getNodeByIdRef— shadow-aware ID lookup.observeDOM— observe each encountered shadow root separately.I am happy to submit a PR with this fix if you are open to it.