Accessibility in Custom Elements
Why custom elements need accessibility work
Section titled “Why custom elements need accessibility work”Native elements like <button> have built-in ARIA roles, focus management, and keyboard handling. Custom elements start with none of this. You must explicitly add what the browser normally provides for free.
When a user navigates with a keyboard or uses a screen reader, the browser exposes an accessibility tree — a parallel representation of the DOM that assistive technologies read. Native elements populate this tree automatically. A <div> or custom element does not, which means screen reader users may receive no information about what the element is or how to interact with it.
ARIA roles and attributes on the host
Section titled “ARIA roles and attributes on the host”The simplest way to describe a custom element’s purpose is to set role and aria-* attributes directly on the host element.
class AccessibleCard extends HTMLElement { connectedCallback() { this.setAttribute('role', 'region'); this.setAttribute('aria-label', this.getAttribute('label') || 'Card'); }}Setting role="region" tells assistive technologies that this element is a landmark region. aria-label gives it an accessible name. Both attributes are reflected into the accessibility tree immediately.
delegatesFocus in attachShadow
Section titled “delegatesFocus in attachShadow”When delegatesFocus: true is passed to attachShadow, clicking on the shadow host (or any non-focusable part inside it) moves focus to the first focusable element inside the shadow root. This is especially useful for custom inputs.
this.shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });Without delegatesFocus, clicking the host element does not move keyboard focus into the shadow DOM. The visual appearance may look focused, but the accessibility tree disagrees, and keyboard users will not be able to interact with the element.
Keyboard handling
Section titled “Keyboard handling”To behave like a button, handle keydown for Enter and Space in connectedCallback and call the same action as the click handler.
Native buttons fire a click event when Enter or Space is pressed. Custom elements do not get this for free. You must listen for keydown and check for those keys explicitly. Always call e.preventDefault() on Space to prevent the page from scrolling.
this.addEventListener('keydown', function(e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); // same action as the click handler }});internals.role and ARIA reflection
Section titled “internals.role and ARIA reflection”Via ElementInternals, you can set ARIA properties directly without polluting the element’s attribute list. For example, internals.role = 'button' and internals.ariaLabel = 'Close'. These ARIA properties reflect to the accessibility tree without being visible as HTML attributes. This keeps your element’s attribute surface clean and avoids conflicts with author-set attributes.
ARIA reflection via ElementInternals requires attachInternals(), which in turn requires static formAssociated = true only if you also need form participation — for pure ARIA reflection you can call attachInternals() without formAssociated.
Runnable demo
Section titled “Runnable demo”The demo below defines a <focus-btn> element that attaches a shadow root with delegatesFocus: true, places a <button> inside it, sets role="button" on the host, and handles keyboard Enter and Space.
Click the button with your mouse, then try pressing Tab to focus it and activating it with Enter or Space. Both paths should update the paragraph below.