Scanning Shadow DOM and Iframes with axe-core
A scan reports what it could see, and it never reports what it could not. A checkout form inside a cross-origin payment frame, a dialog inside a closed shadow root, a chat widget in an iframe that loads two seconds after the scan — all three produce the same output as a perfectly accessible page: zero violations. This guide is part of axe-core Configuration & Setup, and it covers how to configure the scan’s reach across shadow and frame boundaries so a clean result means the content was checked rather than skipped.
Key implementation targets:
- axe injected into every frame, including cross-origin and late-loading ones, before
axe.runwalks the tree. - Context selectors that address content behind shadow and frame boundaries with
fromShadowDomandfromFrames. - A test that fails when a frame or a shadow root was silently skipped, instead of passing on an empty tree.
Root Cause
axe-core builds its own flattened tree before evaluating a single rule, and that tree follows open shadow roots. A <button> inside element.shadowRoot when the root was created with attachShadow({ mode: 'open' }) is scanned like any other node, and its result carries a nested target such as [['app-dialog', 'button.close']] — the inner array is the path across the boundary. Slotted light-DOM content is resolved to where it is assigned, so the flattened tree reflects what a screen reader would traverse rather than what the source order suggests. Nothing needs configuring for that case, which is why the open-shadow case is rarely the one that bites.
A closed root is a different situation entirely. attachShadow({ mode: 'closed' }) means host.shadowRoot is null for every caller, including axe, so the subtree does not exist as far as the tree walk is concerned. The host element itself is still scanned — it will pass aria-allowed-attr and friends quite happily — while the unnamed icon button and the unlabelled input inside it are neither violations nor incomplete results nor even inapplicable. They are absent. No warning is emitted, because axe cannot report on a boundary it has no way to detect from outside.
Frames fail for a mechanical reason instead of an encapsulation one. axe cannot read across a document boundary, so it does not try: the copy of axe running in the top document posts a message to each <iframe> and <frame>, and the copy of axe inside that frame runs the rules against the frame’s own document and posts results back, which the parent merges. If axe is not present in the frame, nothing answers. The parent waits pingWaitTime — 500 ms by default — decides the frame is not instrumented, and moves on. The honest signal for that is the frame-tested rule, which matches frame, iframe and ships with the check option isViolation: false, so an unanswered frame lands in incomplete rather than violations. Two configuration choices commonly delete that signal: frame-tested carries the best-practice tag, so a WCAG-only tag ladder filters it out, and iframes: false in the run options turns off frame traversal altogether.
Configuration
Injection order is what fixes frames, and page.addInitScript is the right hook because it evaluates in every document the page creates — every frame, cross-origin included, and every navigation — before the page’s own scripts run. Injecting at analyze time instead only reaches the frames that already exist, which is why a lazily mounted support widget is the frame that always goes untested. The same init script is also the only reliable place to force shadow roots open in a test build, since custom elements call attachShadow during upgrade, long before a test body executes.
// tests/a11y/fixtures.ts — Playwright fixtures that guarantee reach.
import { test as base, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { source as axeSource, type AxeResults } from 'axe-core';
import { runOptions } from '../../a11y/axe.config.mjs';
type BoundaryFixtures = {
axeReady: void;
scanAll: () => Promise<AxeResults>;
};
export const test = base.extend<BoundaryFixtures>({
// auto: true so every test in the file gets the injection, whether or
// not it asks for scanAll.
axeReady: [
async ({ page }, use) => {
// Runs in EVERY frame, before any page script, on every navigation.
await page.addInitScript({ content: axeSource });
// Test builds only: force closed roots open so axe can walk them.
// Must be an init script — elements upgrade before any test body.
await page.addInitScript(() => {
const original = Element.prototype.attachShadow;
Element.prototype.attachShadow = function (init) {
return original.call(this, { ...init, mode: 'open' });
};
});
await use();
},
{ auto: true },
],
scanAll: async ({ page }, use) => {
await use(() =>
new AxeBuilder({ page })
.options({
...runOptions,
// frame-tested is a best-practice rule; a WCAG-only tag ladder
// filters it out, and it is the only unscanned-frame signal.
rules: { ...runOptions.rules, 'frame-tested': { enabled: true } },
iframes: true, // default; false skips ALL frame traversal
pingWaitTime: 1000, // 500 ms is tight for a CPU-bound frame
frameWaitTime: 20_000, // cap a frame that answers but then stalls
})
.analyze(),
);
},
});
export { expect };
With axe present everywhere, the context selectors decide what is in scope. A plain string or array addresses the top document. The legacy array-of-arrays form crosses one frame per element. The labelled forms are equivalent and self-documenting: fromShadowDom takes one selector per shadow boundary, outermost host first, and fromFrames takes one selector per frame hop. Neither may be nested inside itself, but a fromShadowDom object is allowed as an element of a fromFrames array — that is how you address an iframe that lives inside a shadow root. axe validates the shape and throws on a malformed context rather than quietly scanning the wrong thing.
// a11y/boundary-contexts.mjs — contexts that cross a boundary on purpose.
// Two open shadow hops: <checkout-app> hosts <payment-widget>, which hosts
// the form. One array element per boundary, outermost first.
export const paymentForm = {
include: [{ fromShadowDom: ['checkout-app', 'payment-widget', 'form'] }],
};
// One frame hop. The frame needs its own copy of axe to answer the ping.
export const providerFrame = {
include: [{ fromFrames: ['#provider-frame', 'main'] }],
};
// A frame inside a shadow root: the frame selector may contain a shadow
// selector, but fromFrames may not contain another fromFrames.
export const embeddedProviderFrame = {
include: [
{ fromFrames: [{ fromShadowDom: ['app-shell', 'iframe.provider'] }, 'main'] },
],
};
// A frame you must not instrument (a bank's 3-D Secure step, say): skip
// traversal, keep the rest of the page honest, scan that URL separately.
export const pageWithoutFrames = {
include: ['body'],
exclude: ['#acs-challenge-frame'],
};
When a frame genuinely cannot be instrumented — a sandboxed <iframe> without allow-scripts, or a third-party document you are contractually barred from injecting into — the choice is between iframes: false and an exclude on that frame. Both keep the rest of the page scanned; the difference is that iframes: false disables all frame traversal, so it is the wrong reach for a page with one hostile frame and three of your own. Scan the excluded frame’s URL as its own page in a separate call and merge the two violation arrays under one report entry, so the coverage gap is visible in the artifact rather than implied by its absence.
Validation
The failure signature to test for is a pass on something known to be broken. Put a deliberately unlabelled control inside the shadow root and inside the frame in a fixture route, then assert three things: that the violation fires, that its target proves the boundary was crossed, and that frame-tested reported nothing. A scoped run is the sharpest probe available, because axe throws No elements found for include in page Context when a context selector resolves to nothing — so a shadow path that no longer works fails loudly instead of passing an empty tree.
// tests/a11y/boundaries.spec.ts
import { test, expect } from './fixtures';
import { paymentForm } from '../../a11y/boundary-contexts.mjs';
test('the scan reaches every frame and shadow root', async ({ page, scanAll }) => {
await page.goto('/checkout');
await page.locator('#provider-frame').waitFor(); // frame must exist first
const results = await scanAll();
// 1. No frame was skipped: frame-tested only appears when one was.
const skipped = results.incomplete.filter((r) => r.id === 'frame-tested');
expect(skipped.flatMap((r) => r.nodes.map((n) => n.target))).toEqual([]);
// 2. Reach is proven by targets, not by an empty violation list.
const targets = results.passes.flatMap((r) => r.nodes.map((n) => n.target));
const crossedShadow = targets.some((t) => t.some((s) => Array.isArray(s)));
const crossedFrame = targets.some((t) => t.length > 1);
expect(crossedShadow, 'no shadow-DOM node in the results').toBe(true);
expect(crossedFrame, 'no cross-frame node in the results').toBe(true);
});
test('the shadow path still resolves', async ({ page }) => {
await page.goto('/checkout');
// axe is already in every frame from the init script, so run it directly
// with the scoped context. axe throws "No elements found for include in
// page Context" when any hop is renamed or has become a closed root, so
// a broken path fails the test instead of passing an empty tree.
const violationIds = await page.evaluate(async (context) => {
const axe = (window as unknown as { axe: typeof import('axe-core') }).axe;
const results = await axe.run(context, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },
});
return results.violations.map((v) => v.id);
}, paymentForm);
expect(violationIds).toEqual([]);
});
Run the fixture with the label removed to prove the assertions bite, then restore it. The expected output for a broken shadow control names the host chain in the target, which is the detail that distinguishes a real scan from an empty one.
npx playwright test tests/a11y/boundaries.spec.ts --reporter=list
# Unlabelled <input> inside <payment-widget>'s shadow root:
# ✘ the shadow path still resolves
# 1) label: Form elements must have labels
# target: [["checkout-app","payment-widget","input#pan"]]
# Support widget iframe mounted after the scan started:
# ✘ the scan reaches every frame and shadow root
# Received: [[["#support-frame"]]] <- frame-tested, coverage gap
# After addInitScript injection and a waitFor on the frame:
# ✓ the scan reaches every frame and shadow root (1.9s)
Edge Cases and Conditional Guards
- Closed roots you do not own. The
attachShadowpatch works only for components built in your own bundle; a third-party widget loaded from a separate script may register its elements before your init script if it is injected by a tag manager. When the patch cannot apply, treat the component as unscannable, record it next to your rule overrides, and cover it with a component-level test in the vendor’s own harness rather than pretending the page result includes it. - Frames that navigate mid-test. A frame that reloads after
analyze()began, or an OAuth step that replaces the frame document, drops any script injected imperatively.addInitScriptsurvives this because it re-runs on the new document, but a frame still mid-navigation will miss the ping window; wait for the frame’s own load state before scanning rather than raisingpingWaitTimeto cover it. - Nested and repeated hosts. A component inside another component’s shadow root needs one selector per boundary, and a list of twenty identical hosts needs a selector that matches the host you mean —
fromShadowDom: ['app-list', 'app-row', 'button']addresses the first matching row, not all of them. For repeated widgets, scan the containing region without a shadow path and let the flattened tree walk every instance.
Pipeline Impact
The exit code should treat a coverage gap as a failure, not as a note. frame-tested produces an incomplete result, and incomplete normally does not fail a build — that default is right for rules that could not decide, and wrong here, because “a frame was never scanned” is a fact rather than an uncertainty. Fail the job on frame-tested specifically and keep every other incomplete advisory, which is the same distinction applied when triaging engine limitations in reducing false positives in automated accessibility scanners.
Reach also changes what the report costs. Frame results are merged into the parent’s arrays with frame-prefixed targets, so a page with six frames produces roughly six pages’ worth of nodes, and the per-frame handshake adds real wall-clock time — a stalled frame can hold the run for frameWaitTime, 60 seconds by default. Lower that cap in CI so a hung third-party frame fails fast instead of eating the job timeout, and record the frame count in the artifact alongside the violation list so a drop from six frames to five is visible. The injection fixture belongs in the shared Playwright setup described in integrating axe-core Playwright into an existing project; in a Cypress suite the equivalent step is a custom command that injects into the application frame and each nested frame, since the standard injection helper only covers the top document — see Cypress a11y testing workflows.
Scanning reach is a different problem from rule authoring, and it is worth keeping them apart when a component keeps failing. If axe can see the nodes and no rule expresses the component’s contract, the answer is a custom check, as in writing axe rules for web components and shadow DOM. If the nodes never entered the tree, no rule can help — the context and the injection are what need fixing first.
Common Pitfalls
- Reading zero violations on a frame-heavy page as a pass, when
frame-testedinincompletesays the frames were never entered. - Running a WCAG-only tag ladder, which filters out
frame-testedalong with the rest ofbest-practiceand removes the only coverage signal. - Injecting axe at scan time instead of with an init script, so lazily mounted widgets and post-navigation frames are missed.
- Setting
iframes: falseto silence a single hostile frame, which turns off traversal for every frame on the page. - Leaving the
attachShadowpatch in a production bundle, where forcing every shadow root open changes real component encapsulation. - Writing one selector for a multi-hop shadow path (
'app-shell form') instead of one array element per boundary, which matches nothing and throws. - Raising
pingWaitTimeto paper over a frame that has not finished loading, instead of waiting for the frame and keeping the ping window tight.
FAQ
Does axe-core need any configuration at all for open shadow DOM?
No — open roots and slot assignments are part of the flattened tree that axe builds before evaluating rules, so a control inside an open root is scanned with no extra options. Configuration only becomes necessary when you want to scope a run to something behind a boundary, which is what fromShadowDom is for, or when the root is closed and no traversal is possible. The give-away that a boundary is being crossed is a nested array inside a result’s target.
Why does a cross-origin iframe work at all if the parent cannot read it?
Because the parent never reads it. Each frame runs its own copy of axe against its own document and posts the results back through postMessage, which is permitted across origins, and the parent merges what it receives. That is also why injection is the whole problem: a cross-origin frame without axe inside it cannot participate, no matter what the parent’s context says. Playwright’s init script covers this cleanly since it applies to every document the page creates.
How should an unscannable closed-shadow component be reported? As a known coverage gap with an owner, not as a pass. Record the host element next to the rule overrides so the exclusion is visible in review, and cover the component with its own test in the harness where you control the shadow mode. A page-level report that silently omits the component is worse than one that lists it as untested, because the first version answers a conformance question it never actually checked.
Related
- axe-core Configuration & Setup — the tag ladder, shared config module and context conventions this guide extends.
- How to Configure axe-core for React and Vue Applications — the sibling guide on scan timing for framework-rendered trees.
- Custom Rule Development & Context-Aware Testing — where component contracts become checks once axe can actually see the nodes.