Writing axe Rules for Web Components and Shadow DOM
Custom elements hide their internals behind a shadow root, and slotted light-DOM content lives in a different tree than the markup that renders it. A default rule that calls node.querySelectorAll never crosses the shadow boundary, so a button with no accessible name inside a <my-dialog> shadow root sails through the scan. This guide is part of Component-Specific Rule Writing, and it shows how to write a custom axe-core check that traverses into open shadow roots, validates both shadow-internal ARIA and slotted light-DOM content, and registers cleanly with axe.configure, satisfying WCAG 2.2 SC 1.3.1 (Info and Relationships) and SC 4.1.2 (Name, Role, Value).
Key implementation targets:
- The check descends into open
shadowRoottrees thatquerySelectorAllskips. - Slotted light-DOM content is validated where it is assigned, not just where it is authored.
- Closed shadow roots are reported as unscannable rather than silently passed.
Root Cause / Context
The shadow boundary is a deliberate encapsulation: document.querySelectorAll('button') does not see buttons inside a shadow root, and a shadow root’s <slot> is a placeholder, not the content itself. The slotted nodes physically live in the light DOM as children of the host; the browser projects them through the slot at render time. So a rule that walks the light DOM sees the slotted nodes but not the shadow context that gives them meaning, while a rule that walks the host’s children never enters the shadow tree at all. Either way, half the picture is missing.
axe-core’s built-in rules do handle open shadow DOM via its own flattened-tree traversal, but a custom check you write with a plain evaluate function receives one node and must do the crossing itself. To validate, say, that every interactive control inside a my-dialog has an accessible name — wherever it lives — the check has to recurse through element.shadowRoot and resolve each <slot> to its assignedNodes(). Closed shadow roots expose no shadowRoot property at all, so the honest result there is “cannot scan,” reported as incomplete rather than a false pass.
Configuration
The check below flattens an element subtree across open shadow boundaries and slot assignments, then asserts every interactive control has an accessible name. Register it as a custom check and bind it to a rule scoped to the host element:
// shadow-accessible-name.js
// Walk an element, descending into open shadow roots and resolving slots.
function* flattenTree(root) {
const stack = [root];
while (stack.length) {
const node = stack.pop();
if (node.nodeType !== 1) continue; // elements only
yield node;
if (node.shadowRoot) { // open shadow root: descend
stack.push(...node.shadowRoot.children);
}
if (node.tagName === 'SLOT') { // slot: follow projected light DOM
for (const assigned of node.assignedElements?.() ?? []) stack.push(assigned);
} else {
stack.push(...node.children); // normal light-DOM children
}
}
}
// Minimal accessible-name resolution for interactive elements.
function hasAccessibleName(el) {
const label = el.getAttribute('aria-label');
if (label && label.trim()) return true;
if (el.getAttribute('aria-labelledby')) return true; // assume valid id ref
if ((el.textContent || '').trim()) return true; // visible text content
if (el.matches('input,select,textarea') && el.labels?.length) return true;
return false;
}
const INTERACTIVE = 'button,a[href],input,select,textarea,[role="button"],[tabindex]';
axe.configure({
checks: [{
id: 'shadow-controls-named',
evaluate: function (node) {
// If the host advertises a closed shadow root, we cannot scan it.
if (node.shadowRoot === null && customElements.get(node.localName)) {
this.data({ reason: 'closed-shadow-root' }); // surfaces in 'incomplete'
return undefined; // -> needs review, not pass/fail
}
const controls = [...flattenTree(node)].filter((el) => el.matches(INTERACTIVE));
const unnamed = controls.filter((el) => !hasAccessibleName(el));
this.data({ unnamed: unnamed.length });
return unnamed.length === 0;
},
}],
rules: [{
id: 'web-component-controls-named',
selector: 'my-dialog, [data-a11y-shadow]', // hosts we own and want scanned
tags: ['wcag2a', 'wcag131', 'wcag412'], // SC 1.3.1 and SC 4.1.2
impact: 'serious',
all: ['shadow-controls-named'],
metadata: { description: 'Controls inside the component must have accessible names' },
}],
});
Returning undefined from a check makes axe place the node in incomplete rather than passes or violations, which is exactly right for a closed shadow root you cannot inspect. The Playwright test injects the configuration, runs the scan, and asserts on all three buckets:
// tests/web-component-a11y.spec.ts
import { test, expect } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';
import { readFileSync } from 'node:fs';
const customRule = readFileSync('shadow-accessible-name.js', 'utf8');
test('shadow-DOM controls have accessible names (SC 1.3.1 / 4.1.2)', async ({ page }) => {
await page.goto('/components/dialog');
await page.locator('my-dialog').waitFor();
const results = await new AxeBuilder({ page })
.configure(eval(`(axe) => { ${customRule} }`) as any) // inject our axe.configure
.options({ runOnly: { type: 'rule', values: ['web-component-controls-named'] } })
.analyze();
expect(results.violations, JSON.stringify(results.violations, null, 2)).toEqual([]);
// Closed shadow roots should surface as incomplete, never as silent passes.
for (const inc of results.incomplete) {
expect(inc.id).toBe('web-component-controls-named');
}
});
Validation
Render a my-dialog with a close button that has no accessible name inside its shadow root, and confirm the custom rule reports it even though a default scan would not:
npx playwright test tests/web-component-a11y.spec.ts --reporter=list
# Shadow-internal <button> with no aria-label and no text:
# ✘ shadow-DOM controls have accessible names (SC 1.3.1 / 4.1.2)
# [{ "id": "web-component-controls-named",
# "impact": "serious",
# "nodes": [{ "target": ["my-dialog"], "data": { "unnamed": 1 } }] }]
# After adding aria-label="Close" inside the shadow root:
# ✓ shadow-DOM controls have accessible names (SC 1.3.1 / 4.1.2)
To validate the slot path, place an unnamed <button> as light-DOM child assigned into the dialog’s slot; because flattenTree follows assignedElements(), the same violation fires for slotted content. A closed-shadow fixture should appear in results.incomplete, confirming it is flagged for manual review rather than passed.
Edge Cases & Conditional Guards
- Closed shadow roots:
element.shadowRootisnullfor closed roots, so the check returnsundefinedand the node lands inincomplete. Never treat that as a pass — a closed component still needs a manual audit or a component-level test. - Slotted light DOM: content authored as host children but projected through a slot must be validated at its assignment, not just in document order.
flattenTreefollowsassignedElements()so a slotted control is checked in its rendered context. - Nested custom elements: a component inside another component’s shadow root needs recursion through multiple boundaries. The stack-based walk descends into each
shadowRootit encounters, so deeply nested hosts are covered without special-casing.
Pipeline Impact
The rule gates through the Playwright exit code on violations, but incomplete results — typically closed shadow roots — should be surfaced as a CI warning or a tracked artifact rather than ignored, so unscannable components stay visible. Scope the rule’s selector to the hosts you own; pointing it at every custom element on the page risks flagging third-party widgets you cannot fix. Upload the full axe result JSON as an artifact so reviewers can see the data.unnamed count and the exact host that failed.
Common Pitfalls
- Calling
querySelectorAllfrom the host and assuming it reaches shadow content; it stops at the boundary and silently passes. - Treating a closed shadow root as a pass instead of an
incomplete, hiding components you genuinely cannot scan. - Validating slotted nodes only in the light DOM where they are authored, missing the shadow context that defines their role.
- Scoping the rule selector to all custom elements, which flags third-party components you do not control.
- Forgetting recursion into nested custom elements, so a component-in-a-component escapes the check.
FAQ
Does axe-core not already handle shadow DOM?
axe’s built-in rules traverse open shadow DOM using its own flattened tree. But a custom check written as a plain evaluate(node) only receives the host node and must cross the boundary itself, which is why this check walks shadowRoot and assignedElements() explicitly. For component-specific assertions, you almost always need the manual traversal.
Why return undefined instead of false for a closed shadow root?
A closed root is not necessarily inaccessible — you simply cannot inspect it from outside. Returning false would assert a violation you cannot prove; returning undefined routes the node to axe’s incomplete bucket, which correctly means “needs manual review.” That keeps the gate honest.
How do I get an accessible name for a slotted element reliably?
The simplified resolver here covers aria-label, aria-labelledby, text content, and form-control labels. For production, lean on axe’s own accessible-name computation where you can; the custom walk is for reaching the nodes, after which you can defer naming to a more complete resolver rather than reimplementing the full algorithm.
Related
- Component-Specific Rule Writing — the parent section on scoping custom rules to components.
- Writing Custom axe-core Rules for Complex Data Tables — sibling guide applying the same
axe.configurepattern to grids. - Custom Rule Development & Context-Aware Testing — the broader section on context-aware checks.