ElementInternals and Custom Element Accessibility
ElementInternals is a Web standard API that allows
developers to manage internal behaviors of custom elements, such as form
participation, custom validation, and accessibility semantics. By
providing a bridge to the Accessibility Object Model (AOM),
ElementInternals enables custom elements to expose semantic
roles, states, and properties directly to assistive technologies without
cluttering the host element’s HTML with manual ARIA attributes.
The Limitation of Traditional ARIA in Custom Elements
Historically, developers building Web Components had to set accessibility attributes directly on the custom element’s host tag using JavaScript:
class CustomToggle extends HTMLElement {
connectedCallback() {
this.setAttribute('role', 'switch');
this.setAttribute('aria-checked', 'false');
}
}This approach creates several problems: 1. DOM
Pollution: Internal implementation details leak into the public
DOM. 2. Fragility: Consumers of the component can
accidentally overwrite or remove attributes like role or
aria-*, breaking accessibility. 3. Specificity
& Defaults: There was no native mechanism to define
“default” semantics that external consumers could safely override when
necessary.
How ElementInternals Solves Accessibility
The ElementInternals interface exposes accessibility
properties directly on an internal controller object created within the
component’s constructor via this.attachInternals().
These ARIA properties map directly to the accessibility tree without reflecting as HTML attributes on the host element.
Key Features of Accessibility via ElementInternals
- Default Semantics: Setting
internals.rolesets the default role. If a consumer writes<custom-toggle role="button">, the consumer’s explicit attribute takes precedence, maintaining the standard fallback behavior of native elements. - Encapsulation: States like
aria-expandedoraria-checkedare managed internally in JavaScript without cluttering outer markup. - Full ARIA Reflection: Nearly all standard ARIA
properties (e.g.,
ariaDisabled,ariaLabel,ariaValueNow,ariaHasPopup) are supported directly on theElementInternalsinstance.
Implementation Example
Below is an implementation of an accessible toggle button using
ElementInternals:
class AccessibleSwitch extends HTMLElement {
#internals;
#checked = false;
constructor() {
super();
// 1. Attach the ElementInternals instance
this.#internals = this.attachInternals();
// 2. Set default accessibility semantics
this.#internals.role = 'switch';
this.#internals.ariaChecked = 'false';
this.addEventListener('click', this.#toggle.bind(this));
this.addEventListener('keydown', this.#handleKey.bind(this));
}
connectedCallback() {
// Ensure the element is focusable if not disabled
if (!this.hasAttribute('tabindex')) {
this.tabIndex = 0;
}
}
#toggle() {
this.checked = !this.checked;
}
#handleKey(event) {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
this.#toggle();
}
}
get checked() {
return this.#checked;
}
set checked(value) {
this.#checked = Boolean(value);
// 3. Update the accessibility state dynamically
this.#internals.ariaChecked = this.#checked ? 'true' : 'false';
}
}
customElements.define('accessible-switch', AccessibleSwitch);Common ARIA Properties Supported by ElementInternals
ElementInternals supports camelCase versions of standard
ARIA attributes:
ariaChecked: Accepts'true','false', or'mixed'.ariaDisabled: Accepts'true'or'false'.ariaExpanded: Accepts'true','false', or'undefined'.ariaLabel: Accepts a string representing the accessible name.ariaValueNow,ariaValueMin,ariaValueMax: Accepts numbers as strings to manage range-based components like sliders and progress meters.role: Accepts standard ARIA role strings such as'button','checkbox','progressbar', or'tab'.
Using ElementInternals provides a robust, standardized
mechanism for creating custom elements that behave like native browser
elements in the accessibility tree.