Writing Axe Rules for Web Components and Shadow DOM

A check that reads the DOM with querySelector, closest and getElementById is written against a single node tree. A custom element with an open shadow root has at least two, and the interesting relationships in the component run between them: the control lives in the shadow tree, the label the user reads is slotted in from the document tree, and the reference that is supposed to connect them cannot see across the join. This guide is part of Component-Specific Rule Writing, and it covers the three APIs a check needs to reason across that join — virtualNode, axe.utils.getComposedParent and axe.commons.text.accessibleTextVirtual — plus the naming mechanisms that actually work when an aria-labelledby reference does not.

A separate concern, deliberately out of scope here, is getting the scanner to reach shadow content and frames in the first place: the include/exclude context syntax, frame selectors and legacy mode belong to scanning shadow DOM and iframes with axe-core. Everything below assumes the nodes are already in the scan and asks a different question: once the check has the node, how does it reason about relationships that span a boundary?

Root Cause

The running example is a design-system switch. The consumer writes <ds-switch><span id="notify-label">Email notifications</span></ds-switch>, and the element’s constructor attaches an open shadow root containing <span role="switch" tabindex="0" aria-checked="false" aria-labelledby="notify-label"></span> followed by a <slot>. Rendered, this looks perfect: a switch with a visible label beside it. In the accessibility tree it is a switch with no name at all, because an aria-labelledby value is resolved against the tree containing the referencing element, and #notify-label does not exist in the shadow root. The reference dangles. No built-in rule reports it either, because aria-valid-attr-value treats an unresolvable aria-labelledby inside a shadow root as needs-review rather than a failure, and the node still has a role, a tabindex and an aria-checked, so nothing else objects.

Writing a check for this looks trivial until the applicability logic is written. The rule selector is [role="switch"], which matches the shadow-internal span — axe’s flattened tree includes open shadow content, so custom rules do see it. The natural next step is node.closest('ds-switch') to confirm the switch belongs to the design system, and that call returns null. closest walks the parentElement chain, and the shadow-internal span’s chain terminates at the ShadowRoot, which is a DocumentFragment and therefore has no parentElement. The walk stops one hop short of the host. This is the silent failure that costs the most debugging time: the rule does not error, it does not fail, it becomes inapplicable to every instance of the component in the application, and the report comes back green for a component nobody ever checked.

axe hands evaluate a third argument for exactly this reason. virtualNode is axe’s wrapper around the element, built once per scan as part of the flattened tree, and its parent pointer follows composition rather than the raw node tree — the shadow-internal span’s virtual parent is the ds-switch host. Alongside it, axe.utils.getComposedParent(element) does the same hop for a real DOM node, returning the shadow host when the node’s parentNode is a shadow root. Both give a check a traversal that behaves the way the rendered page behaves, and virtualNode.shadowId gives it a way to prove the diagnosis: two nodes in the same tree share a shadowId, and two nodes that do not share one can never be connected by an IDREF, no matter how the attribute is spelled.

Two id scopes inside one rendered component The ds-switch host branches into a shadow root holding the switch element and a slot, and into the document tree holding the span that carries the label id. A rose arrow marked with a cross shows the aria-labelledby reference failing to reach the other id scope, and a green band gives the working alternative. Two id scopes, one rendered component ds-switch (host) shadow root · id scope A span role="switch" aria-labelledby="notify-label" span > slot (projection point) document tree · id scope B span id="notify-label" "Email notifications" assigned into the slot aria-labelledby cannot reach id scope B, so the name resolves to empty Reference the shadow-internal span that wraps the slot instead Name computation follows slot assignment, so the projected text still becomes the name.
Both spans render side by side, but they live in different id scopes, and an IDREF is the one relationship that cannot bridge them.

Configuration

The rule below matches every role="switch" in the flattened tree, uses a composed-ancestor walk to keep only the ones the design system owns, and asserts the composed accessible name from inside evaluate. The interesting part is that it also diagnoses the failure: when an aria-labelledby id is missing from the referencing node’s own root but present in the document, the check reports that specific cross-boundary reference rather than a generic “no accessible name”.

// a11y/rules/checks/ds-switch-composed-name.js

// closest() stops at the shadow root. getComposedParent hops to the host,
// so this walk reaches ancestors in every tree the component is built from.
function composedAncestor(element, nodeName) {
  let current = axe.utils.getComposedParent(element);
  while (current) {
    if (current.nodeName === nodeName) return current;
    current = axe.utils.getComposedParent(current);
  }
  return null;
}

export const dsSwitchComposedName = {
  id: 'ds-switch-composed-name',
  metadata: {
    impact: 'critical',
    messages: {
      pass: 'The switch resolves a composed name from its slotted label',
      // Names the exact broken reference and the mechanism that replaces it.
      fail: 'aria-labelledby="${data.crossBoundary}" points outside this shadow root; '
        + 'reference the shadow-internal wrapper around <slot> instead',
    },
  },
  evaluate: function (node, options, virtualNode) {
    // accessibleTextVirtual walks axe's flattened tree, so it follows slot
    // assignment. accessibleText(node) resolves IDREFs in one tree only.
    const name = axe.commons.text.accessibleTextVirtual(virtualNode).trim();

    const refs = (virtualNode.attr('aria-labelledby') || '')
      .trim().split(/\s+/).filter(Boolean);
    // getRootNode() is the ShadowRoot here; DocumentFragment has getElementById.
    const root = node.getRootNode();
    const unresolved = refs.filter((id) => !root.getElementById(id));

    // An id that is missing here but present in the document is the tell:
    // the author intended a reference the platform will never make.
    const crossBoundary = unresolved.filter((id) => {
      const target = document.getElementById(id);
      if (!target) return false;
      const targetVNode = axe.utils.getNodeFromTree(target);
      // Different shadowId means different tree, so no IDREF can connect them.
      return !targetVNode || targetVNode.shadowId !== virtualNode.shadowId;
    });

    this.data({ name, crossBoundary: crossBoundary.join(' ') });
    this.relatedNodes(crossBoundary.map((id) => document.getElementById(id)));
    if (crossBoundary.length > 0) return false;
    return name.length > 0;
  },
};

export const dsSwitchRule = {
  id: 'ds-switch-named',
  selector: '[role="switch"]',
  matches: function (node) {
    // node.closest('ds-switch') would return null for a shadow-internal node
    // and make this rule inapplicable to every instance of the component.
    return composedAncestor(node, 'DS-SWITCH') !== null;
  },
  tags: ['wcag2a', 'wcag412', 'ds-switch', 'custom'],
  metadata: {
    description: 'A switch inside ds-switch must expose a composed accessible name',
    help: 'Wrap the slot in a span with an id and point aria-labelledby at that span',
  },
  all: ['ds-switch-composed-name'],
};

Inside a check, virtualNode.parent is the cheaper equivalent of the walk above, because the flattened tree is already built and each hop is a property read rather than a DOM query. The loop becomes let v = virtualNode.parent; while (v && v.actualNode.nodeName !== 'DS-SWITCH') v = v.parent;. Prefer it in evaluate, where the virtual node is always in hand, and keep getComposedParent for matches, which receives the virtual node as a second argument only in recent axe-core versions and is therefore the safer place to work from the real node.

closest stops at the shadow root, getComposedParent does not Two upward ladders start from the same span with role switch. The left ladder, using node.closest, reaches the shadow root and returns null because a shadow root is not an element. The right ladder, using axe.utils.getComposedParent, hops from the shadow root to the ds-switch host and succeeds. node.closest('ds-switch') axe.utils.getComposedParent() host never found host found in three hops #shadow-root not an Element: null span (shadow wrapper) span role="switch" ds-switch (host) span (shadow wrapper) span role="switch" The composed parent of a node whose parentNode is a shadow root is the host element.
Both walks start on the same node; only the composed walk reflects the tree the browser actually rendered.

Validation

Prove the rule against three fixtures: the broken cross-boundary reference, the shadow-internal wrapper that works, and a switch that belongs to somebody else and must be inapplicable. A jsdom harness of the kind described in unit-testing custom axe rules with Jest fixtures cannot help here, because jsdom’s flattened-tree support is partial and the composed walk is precisely what needs exercising — run the whole rule in a real browser instead of calling evaluate directly.

// tests/a11y/ds-switch.browser.test.ts  (Vitest browser mode, Chromium)
import { describe, it, expect, beforeAll } from 'vitest';
import axe from 'axe-core';
import {
  dsSwitchComposedName, dsSwitchRule,
} from '../../a11y/rules/checks/ds-switch-composed-name.js';

function defineSwitch(shadowHtml: (labelId: string) => string) {
  class DsSwitch extends HTMLElement {
    connectedCallback() {
      if (this.shadowRoot) return;
      // Open root: axe's flattened tree can see inside it.
      this.attachShadow({ mode: 'open' }).innerHTML = shadowHtml(this.id + '-label');
    }
  }
  const tag = `ds-switch`;
  if (!customElements.get(tag)) customElements.define(tag, DsSwitch);
}

beforeAll(() => {
  axe.configure({ checks: [dsSwitchComposedName], rules: [dsSwitchRule] });
});

async function run(hostHtml: string) {
  document.body.innerHTML = hostHtml;
  await customElements.whenDefined('ds-switch');
  return axe.run(document.body, {
    runOnly: { type: 'rule', values: ['ds-switch-named'] },
  });
}

describe('ds-switch-named', () => {
  it('fails on an aria-labelledby that points out of the shadow root', async () => {
    defineSwitch((id) => `<span role="switch" tabindex="0" aria-checked="false"
      aria-labelledby="${id}"></span><slot></slot>`);
    const r = await run('<ds-switch id="notify"><span id="notify-label">Email</span></ds-switch>');
    expect(r.violations).toHaveLength(1);
    expect(r.violations[0].nodes[0].all[0].message).toContain('points outside this shadow root');
  });

  it('passes when the reference targets the wrapper around the slot', async () => {
    document.body.innerHTML = '';
    const host = document.createElement('div');
    host.attachShadow({ mode: 'open' }).innerHTML = `
      <span role="switch" tabindex="0" aria-checked="false" aria-labelledby="lbl"></span>
      <span id="lbl"><slot></slot></span>`;
    // Same tree for reference and target; the slot supplies the text.
    const results = await axe.run(host.shadowRoot!.firstElementChild!, {
      runOnly: { type: 'rule', values: ['ds-switch-named'] },
    });
    expect(results.violations).toEqual([]);
  });
});

The first test is the one that regresses. When the component team later moves the switch markup into a nested <ds-switch-track> element, composedAncestor still finds DS-SWITCH two hops further up, whereas any hard-coded single-hop lookup silently stops matching and the test turns green for the wrong reason. That is why the assertion checks the message text rather than only the violation count.

Three ways to name a shadow-internal control Row one, an aria-labelledby reference from inside the shadow root to a light-DOM id, produces an empty name because IDREFs are tree scoped. Row two, an aria-labelledby reference to a shadow-internal span wrapping the slot, produces a composed name. Row three, ElementInternals ariaLabel set on the host, produces a name with no id references at all. Three ways to name a shadow-internal control aria-labelledby from inside the shadow root to a light-DOM id empty name IDREF is tree-scoped aria-labelledby to a shadow-internal span that wraps the slot composed name slot text is used ElementInternals ariaLabel set on the host from its text content name on the host no id refs at all Only the first crosses an id boundary, which is the one thing an IDREF cannot do.
The check should accept either working mechanism, because a component may reasonably name the host or name the internal control.

Edge Cases and Conditional Guards

  • A closed shadow root is invisible, not broken. attachShadow({ mode: 'closed' }) leaves element.shadowRoot as null and keeps the subtree out of axe’s flattened tree entirely, so the shadow-internal switch is never offered to any check. Guard at the host level with a second rule whose selector is ds-switch and whose check returns undefined when node.shadowRoot === null and customElements.get(node.localName) is truthy — an incomplete result that says “cannot inspect”, never a pass.
  • The composed parent of a slotted node is not its light-DOM parent. For a node assigned to a slot, getComposedParent returns the slot’s parent inside the shadow tree, because that is where the node renders. An ownership walk starting from the slotted label therefore lands inside the component rather than in the document, which is correct for naming questions and wrong for questions about the consumer’s markup. Use node.parentElement when the question is “who authored this”, and the composed walk when the question is “where does this render”.
  • aria-owns and aria-activedescendant have the same tree scope as aria-labelledby. Any attribute whose value is an IDREF is resolved in the referencing element’s own root, so a shadow-internal listbox cannot own light-DOM options and a shadow-internal input cannot point aria-activedescendant at an option in the document. When the check compares shadowId values, it can report all of these with one code path; the combobox-specific version of that assertion is worked through in writing a custom axe rule for a combobox pattern.

Pipeline Impact

A cross-boundary naming failure is critical, so it belongs in the blocking set: the control is genuinely unusable with a screen reader, and the fix is a two-line change inside the component. Incomplete results from closed shadow roots are the opposite — they should never fail a build, because nothing in the consuming repository can resolve them. Route them to a separate annotation stream and track the count over time; a rising number of unscannable components is a procurement and architecture signal, not a pull-request problem.

Because the rule reasons about a component’s internals, it belongs in the component package’s own test run rather than in the application scan, where it would report the same violation once per instance and drown the report. Publish the rule with the component, version it alongside the component, and let application pipelines consume the built bundle as described in the custom rule testing and distribution guide. One artifact per run should carry the data.crossBoundary value, because that string is the entire diagnosis and it costs nothing to keep.

Common Pitfalls

  • Using node.closest() in matches for a node that lives in a shadow root, which returns null and quietly makes the rule inapplicable to every instance of the component.
  • Calling axe.commons.text.accessibleText(node) instead of accessibleTextVirtual(virtualNode), so slot-projected text is missed and correctly named controls are reported as unnamed.
  • Reading document.getElementById to validate an aria-labelledby value on a shadow-internal node; the document is the wrong root, and the check will pass exactly the references that are broken.
  • Treating a closed shadow root as a pass because no node inside it was reported, rather than adding a host-level rule that returns undefined.
  • Assuming a single getComposedParent hop reaches the host, which breaks the moment the component nests one more wrapper element inside its shadow tree.
  • Fixing a dangling reference by copying the label’s id into the shadow root, which creates two elements with the same id in different trees and makes the next debugging session much harder.

FAQ

Do axe-core’s built-in rules already handle open shadow DOM? They do, because axe builds a flattened tree once per run and its own checks are written against that tree rather than against document. The gap is not in the built-ins, it is in custom checks: a hand-written evaluate that reaches for document, closest or getElementById opts out of the flattened tree and reintroduces the boundary that axe already solved. Using virtualNode and the axe.utils helpers is how a custom check inherits the same traversal.

Why does aria-labelledby not cross the shadow boundary when the element clearly renders next to the label? Rendering and referencing use different trees. Composition decides what the user sees, and an IDREF is resolved by looking up the id in the node tree that contains the referencing element — the shadow root, in this case. That asymmetry is intentional encapsulation: if references crossed freely, any page could reach into a component’s internals by guessing ids, and every component would break when a consumer happened to reuse one of its ids.

Should the check assert on the host or on the internal control? Assert on whichever node carries the role, and use the composed walk to establish ownership from there. If the component sets its semantics with ElementInternals on the host, the host carries the role and the rule selector should be the element name; if the semantics live on an internal element, the selector should be the role and matches should confirm the host above it. Writing the rule against both at once produces duplicate reports for a single defect, which is the fastest way to make the rule look unreliable.