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.run walks the tree.
  • Context selectors that address content behind shadow and frame boundaries with fromShadowDom and fromFrames.
  • 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.

What a default axe run can and cannot reach From the top document, an open shadow root is walked as part of the flattened tree and a frame containing axe returns merged results, while a closed shadow root is entirely absent from the tree and a frame without axe is only reported through the frame-tested incomplete result. axe.run in top document builds the flattened tree open shadow root mode: 'open' slots resolved closed shadow root shadowRoot is null host only frame with axe answers the ping results merged frame without axe no answer in 500 ms contents skipped scanned nested target path invisible no result of any kind scanned frame-first target incomplete frame-tested only
Only one of the four cases produces no signal at all: a closed shadow root is absent from the tree, so nothing in the result set hints that content was skipped.

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.

Cross-frame handshake during a single axe run Time runs downward in three lanes: the top document posts a ping to both frames, the instrumented frame replies with results that are merged into the parent report, and the uninstrumented frame never replies so the parent gives up at the ping wait time and records a frame-tested incomplete result. top document frame with axe frame without axe ping via postMessage ping via postMessage rules run, results returned merged into the parent report silence until pingWaitTime give up: frame contents never evaluated incomplete: frame-tested the only trace of the gap violations: 0 for that frame indistinguishable from a clean frame
The parent never learns what was in the silent frame, so the run reports zero violations for it — which is why the frame-tested incomplete result has to be treated as a coverage failure.

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)
Context forms and the boundaries they cross Five rows pair a context literal with the boundary it addresses: a plain selector reaches the top document only, the legacy array-of-arrays and fromFrames forms each cross one frame, fromShadowDom crosses two open shadow roots, and a fromShadowDom object inside a fromFrames array reaches a frame that lives inside a shadow root. context form boundary it crosses include: ['button'] top document only include: [['#pay-frame', 'button']] one frame hop, legacy form include: [{ fromFrames: ['#pay-frame', 'button'] }] one frame hop, validated include: [{ fromShadowDom: ['app-shell', 'form'] }] two open shadow roots include: [{ fromFrames: [ { fromShadowDom: ['app-shell', 'iframe'] }, 'main'] }] shadow root, then the frame in it Neither labelled form may be nested inside itself; axe throws on a malformed context.
One array element per boundary hop is the whole rule, and the labelled forms fail loudly on a bad shape where the legacy nested arrays do not.

Edge Cases and Conditional Guards

  • Closed roots you do not own. The attachShadow patch 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. addInitScript survives 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 raising pingWaitTime to 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-tested in incomplete says the frames were never entered.
  • Running a WCAG-only tag ladder, which filters out frame-tested along with the rest of best-practice and 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: false to silence a single hostile frame, which turns off traversal for every frame on the page.
  • Leaving the attachShadow patch 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 pingWaitTime to 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.