Testing Custom Elements That Use ElementInternals
How Axe DevTools for Web reads ARIA semantics from ElementInternals, what your components must do to expose them, and which languages support it.
Web components can declare their accessibility semantics in JavaScript instead of in markup, by calling attachInternals() and setting properties such as role and ariaLabel on the returned ElementInternals object. A component built this way has no role or aria-* attributes in the DOM, so a testing tool that only reads attributes sees an element with no semantics at all.
Axe DevTools for Web now reads these semantics. This page explains which versions support it, what your components must do to make their internals visible, and how internals interact with the attributes already on the element. For general information on selecting your axe-core version, see Customizing Rules.
Supported Languages and Versions
Support comes from the underlying accessibility testing engine, so it is available wherever the bundled axe-core version is 4.13.0 or higher.
| Language | Version that added support | Bundled axe-core |
|---|---|---|
| C# | 4.13.0 | 4.13.0 |
| Python | 4.13.0 | 4.13.0 |
| Ruby | 4.13.0 | 4.13.0 (through axe-core-api 4.13.0) |
| Java | 4.13.0 | 4.13.0 (through com.deque.html.axe-core 4.13.0) |
| Node.js and the CLI | 4.13.0 | 4.13.0 |
There is nothing to turn on. In every language above, the accessibility testing engine runs directly in the page under test, so it reads ElementInternals on every scan with no option, flag, or configuration change. If your components already follow the protocol described below, upgrading is all that is required.
Exposing ElementInternals to Axe DevTools
ElementInternals is private by design: JavaScript provides no API for reading another element's internals. A testing tool can only see the object if your component deliberately publishes it. Component authors do this through a community protocol, and Axe DevTools supports every form of that protocol.
The recommended form is a global WeakMap, which keeps the object off the element itself:
customElements.define(
'my-custom-button',
class MyCustomButton extends HTMLElement {
constructor() {
super();
const internals = this.attachInternals();
internals.role = 'button';
globalThis._elementInternals ??= new WeakMap();
globalThis._elementInternals.set(this, internals);
}
}
);Assigning the object to a property on the element also works:
customElements.define(
'my-custom-button',
class MyCustomButton extends HTMLElement {
constructor() {
super();
this._internals = this.attachInternals();
this._internals.role = 'button';
}
}
);Besides the globalThis._elementInternals map, Axe DevTools looks for the object under any of these names on the element:
_internals(recommended)internalsinternals_Symbol('internals')Symbol('privateInternals')
A component that keeps its internals genuinely private is invisible to Axe DevTools, and this is the most common reason for seeing no change after upgrading. Two cases to check:
- Private class fields.
this.#internals = this.attachInternals()cannot be read from outside the class, so no testing tool can see it. Several component libraries use this pattern. - Getters. A property that is defined as an accessor rather than a plain value is skipped, even if it is named
_internals. Assign the object directly.
In both cases the fix belongs in the component, not in your test configuration. Publish the object through one of the forms above, or expect the element to be evaluated as though it had no ARIA semantics.
Testing that your components are visible to Axe DevTools does not need a scan. In the browser console on a page that uses the component, globalThis._elementInternals?.get(document.querySelector('my-custom-button')) or document.querySelector('my-custom-button')._internals should return an ElementInternals object rather than undefined.
What Axe DevTools Reads
Once the object is visible, Axe DevTools reads the role property and the ARIA properties that correspond to aria-* attributes, such as ariaLabel for aria-label, ariaDescription for aria-description, and ariaLabelledByElements for aria-labelledby. These values feed the same rules that would have run against the equivalent attributes, so a custom element that sets role and ariaLabel through internals is checked for an accessible name in the same way as one that sets role and aria-label in markup.
Precedence
Values that are present in the DOM always win. This means adding attributes to your markup is a reliable way to override what a component declares internally, and that internals never mask a problem that is visible in the DOM.
- Role. The
rolefromElementInternalsis treated as the element's implicit role, the same standing as the built-in role of a native element. An explicitroleattribute on the element overrides it. - ARIA values. Each value is resolved from the attribute first, then the corresponding property on the element, and only then from
ElementInternals.
Limitations
Support is real but not yet complete, and the gaps are worth knowing before you interpret a scan.
Rules that select on attributes do not match internals-only elements. A number of rules identify the elements they apply to with a CSS selector against the DOM. Because an element whose semantics come only from ElementInternals has no role attribute, those rules never run against it. For example, aria-required-attr selects [role], and aria-command-name selects [role="link"], [role="button"], [role="menuitem"]. A custom element declaring role = 'button' through internals is not evaluated by either rule. Rules that resolve the role rather than match a selector, such as those computing an accessible name, do apply.
Role values are not validated. A role set through internals is used as given. An invalid or misspelled value is not reported the way an invalid role attribute would be.
Some rules are only partially applied. Rules that inspect an element's relationship to its children or ancestors, such as aria-required-children, may not evaluate an internals-only element fully.
Because of the first limitation in particular, expect a component that declares its semantics only through internals to produce fewer findings than the equivalent markup would, rather than more. A clean scan of such a component is weaker evidence than a clean scan of one using attributes.
Related Pages
- Customizing Rules for selecting the axe-core version your scans use.
- About Axe DevTools for Web APIs for the full list of supported languages and frameworks.
- C# API Documentation, Python overview, Advanced API Usage with Ruby, Java API Overview, and Node.js and Browser-based Testing for the language bindings that support this.
