Skip to content

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.

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.

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.

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
}
});

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.

A real limitation to know about: ID-reference ARIA attributes — aria-labelledby, aria-describedby, aria-activedescendant, and the <label for=""> attribute — can only point to an element in the same node tree. An ID inside a shadow root cannot be referenced from the light DOM, and vice versa, so a light-DOM <label for="my-id"> cannot label an <input> hidden inside your shadow root. The WICG “Reference Target” proposal aims to fix this with a referenceTarget option on attachShadow (or a shadowrootreferencetarget attribute on <template>) that forwards ID references arriving at the host to one designated element inside the shadow root — but as of this writing it is still a proposal under development, not a shipped browser feature. Until it lands, set role/aria-label directly on the host, or use ElementInternals, to avoid needing a cross-root ID reference at all.

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.

OptionBenefitCost
Set ARIA via setAttribute('role', ...) on the hostEasy to write, directly inspectable via the DOM inspectorThe attribute can be unintentionally overridden by whoever authors the HTML that uses your element
Set ARIA via internals.role / internals.ariaLabel (ElementInternals)Doesn’t clutter the element’s attribute list and can’t clash with author-set attributesRequires calling attachInternals() first, and older browsers support it less broadly than a plain attribute
  • Forgetting delegatesFocus — building a custom input with a focusable element inside shadow DOM but not passing delegatesFocus: true to attachShadow means clicking the host never moves focus inside, leaving keyboard users confused about why Tab doesn’t reach the expected element.
  • Skipping keyboard handling on an interactive element — making an element act like a button with role="button" but not handling keydown for Enter/Space makes it completely unusable for keyboard and screen-reader users, even though it looks clickable with a mouse.
  • Setting an ARIA role without setting tabindex — adding role="button" alone doesn’t automatically make the element focusable the way a native <button> is; you also need tabindex="0", or the element gets skipped when tabbing.

💡 Real-world examples

GitHub — ships custom elements (<relative-time>, <clipboard-copy>, etc.) that go through rigorous accessibility testing, so screen reader users get the same functionality as mouse users. (github/relative-time-element)

Adobe Spectrum Web Components — designs every component to support delegatesFocus and ARIA reflection via ElementInternals, so it’s usable in enterprise design systems that must meet accessibility standards. (opensource.adobe.com)

What does delegatesFocus: true do when passed to attachShadow?
Which keyboard keys should a custom element acting as a button respond to?
How can internals.role differ from setAttribute("role", ...)?