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 shadowRoot trees that querySelectorAll skips.
  • 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.
Host, open shadow root, and slotted light DOM traversal A custom element host has an open shadow root containing internal nodes and a slot; light-DOM children are assigned into the slot and must be validated there. host: my-dialog custom element open shadowRoot role=dialog + slot internal buttons slotted light DOM assignedNodes() custom check SC 1.3.1 / 4.1.2
The check must reach into the open shadow root and follow slot assignments to validate both trees.

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.shadowRoot is null for closed roots, so the check returns undefined and the node lands in incomplete. 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. flattenTree follows assignedElements() 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 shadowRoot it 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 querySelectorAll from 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.