Accessibility & Focus Management
Custom elements that wrap interactive controls inside a shadow root inherit none of the platform’s built-in semantics or focus behavior, so accessibility must be engineered deliberately rather than assumed. This guide within Core Architecture & Lifecycle Management details how to expose roles and states, delegate focus across shadow boundaries, and keep keyboard navigation correct in framework-agnostic Web Components built with Shadow DOM.
The same constructor-phase decisions that establish encapsulation — calling attachShadow(), attaching ElementInternals, choosing where focusable nodes live — also fix the accessibility tree and the tab order. A component that renders correctly but is invisible to assistive technology or untabbable by keyboard is, for an audited design system, broken. The boundary that protects your styles also hides your semantics, and the work here is closing that gap without leaking implementation details.
Concept Definition & Spec Grounding
Accessibility for a custom element is governed by several intersecting specifications. The WHATWG HTML Standard defines attachShadow(), the delegatesFocus option, sequential focus navigation, and the inert attribute. The WHATWG DOM Standard defines how the shadow tree composes into the flattened tree that assistive technology actually reads. The WAI-ARIA specification and the ARIAMixin interface — surfaced on an element’s ElementInternals object via the Accessibility Object Model (AOM) work — define the role and state vocabulary. CSS Selectors Level 4 defines :focus-visible.
Two facts about the shadow boundary drive every decision on this page. First, the accessibility tree is built from the flattened tree, so a control inside a shadow root is exposed to a screen reader as if it were composed into its slot position — encapsulation does not hide semantics from assistive technology. Second, IDREF-based relationships such as aria-labelledby, aria-controls, and aria-activedescendant resolve only within a single tree scope. An attribute in the light DOM cannot point at an id inside a shadow root, and vice versa. That single restriction is the root of most cross-component accessibility bugs, and it is the reason the platform is adding new mechanisms described below.
Focus is the second half of the story. The DOM spec defines a sequential focus navigation order; a custom element participates in it only if it is itself focusable (it has a tabindex) or if it delegates focus to a focusable descendant. Getting roles right without getting focus right produces a component a screen reader can describe but a keyboard user cannot operate.
The :focus-visible pseudo-class deserves explicit grounding because it is frequently confused with :focus. CSS Selectors Level 4 defines :focus-visible to match a focused element only when the user agent’s heuristic determines a focus indicator should be shown — in practice, for keyboard and programmatic focus, but not for a pointer click on a control that does not normally show a ring. This distinction matters more inside Shadow DOM than in the light DOM, because component authors ship their own focus styling and a naive :focus rule produces rings that flash on every mouse interaction, which design-system reviewers reject. Throughout this topic area the rule is to style the delegated focus target with :focus-visible, never bare :focus, so the keyboard experience is correct without punishing pointer users.
Browser Engine Integration Points
When the parser or attachShadow() builds a shadow root, the engine records the delegatesFocus flag on the root and links the shadow tree to its host for flattened-tree composition. During the focus-handling steps, if a user clicks anywhere in a shadow host whose root has delegatesFocus: true, or if script calls host.focus(), the engine runs the “get the focusable area” algorithm, which descends into the shadow tree and focuses the first focusable descendant instead of the host. The :focus and :focus-within pseudo-classes then match the host as well, which is what makes focus rings render in the expected place.
The accessibility tree is computed by the engine’s accessibility module after style and layout, walking the flattened tree. Default semantics set through ElementInternals are read at this stage as the element’s implicit role and state. Crucially, the engine resolves them with lower priority than matching attributes on the host: if the consumer writes role="presentation" or aria-label="…" on the host element in the light DOM, those values win over the internals defaults. This is the platform’s deliberate mechanism for letting a component ship sensible defaults that consumers can still override, and it composes naturally with the Shadow DOM Construction & Modes decisions that created the root.
The inert attribute is processed during the same flattened-tree walk: an inert subtree is removed from the tab order, has pointer events suppressed, and — importantly for accessibility — is pruned from the accessibility tree, so it composes correctly across shadow boundaries when a host or a slotted region is marked inert. This makes inert the correct primitive for modal patterns inside a component: marking everything except the active dialog inert traps focus and hides the rest of the page from assistive technology in a single declarative step, without the brittle bookkeeping of saving and restoring every descendant’s tabindex. Because the engine applies inert to the flattened tree, a host marked inert also makes its shadow subtree inert, which is exactly the behavior a component author wants when disabling an entire region.
A subtle but important consequence of the flattened-tree model is timing. The accessibility tree is recomputed after style and layout, which means a default role or ARIA value set in the constructor is not observable to assistive technology until the element is upgraded and rendered. For components defined before they are parsed this is instantaneous, but for elements that are server-rendered and only upgraded during hydration, there is a window where the element exists in the DOM with no semantics. This is why critical roles should be considered alongside the serialized markup, not assumed to be present from the constructor alone.
Core API Surface
The table below summarizes the primitives this topic area covers, where each lives, and what it controls.
| Primitive | Where it is set | Controls |
|---|---|---|
attachShadow({ delegatesFocus: true }) |
Constructor | Forwards focus from host to first focusable shadow descendant; makes :focus match the host |
tabindex="0" / tabindex="-1" |
Host or shadow nodes | Whether the element participates in sequential focus; programmatic-only focus |
internals.role |
attachInternals() ARIAMixin |
Default implicit role (overridable by host role) |
internals.ariaLabel, ariaValueNow, ariaExpanded, … |
ARIAMixin on internals | Default ARIA states/properties (overridable by host aria-*) |
:focus-visible |
CSS in shadow root | Heuristic focus ring only for keyboard/programmatic focus |
inert |
Host or any element | Removes subtree from tab order and accessibility tree |
| Reference Target (proposal) | internals.ariaActiveDescendantElement, shadowRoot.referenceTarget |
Cross-root IDREF relationships without exposing internal ids |
class RatingStars extends HTMLElement {
#internals;
#root;
#value = 0;
constructor() {
super();
// delegatesFocus forwards host.focus() and host clicks to the inner control
this.#root = this.attachShadow({ mode: 'open', delegatesFocus: true });
this.#internals = this.attachInternals();
// Default semantics live on internals, not on host attributes,
// so a consumer's role="..." / aria-label="..." can still override them.
this.#internals.role = 'slider';
this.#internals.ariaValueMin = '0';
this.#internals.ariaValueMax = '5';
this.#internals.ariaValueNow = '0';
this.#internals.ariaLabel = 'Rating';
this.#root.innerHTML = `
<style>
:host { display: inline-flex; outline: none; }
.track { display: inline-flex; gap: 4px; }
.track:focus-visible { outline: 2px solid var(--focus-ring, #5a7bff); }
</style>
<span class="track" part="track" tabindex="0">★★★★★</span>`;
this.#root.querySelector('.track')
.addEventListener('keydown', (e) => this.#onKey(e));
}
#onKey(e) {
if (e.key === 'ArrowRight') this.value = Math.min(5, this.#value + 1);
if (e.key === 'ArrowLeft') this.value = Math.max(0, this.#value - 1);
}
set value(v) {
this.#value = v;
this.#internals.ariaValueNow = String(v);
}
get value() { return this.#value; }
}
customElements.define('rating-stars', RatingStars);
Production Implementation Pattern
A robust accessible component combines all four mechanisms: delegated focus so the host is the single focus entry point, default semantics on internals so the component announces correctly out of the box, keyboard handlers that update state and mirror it to ARIAMixin, and :focus-visible so the focus ring appears only when it should. The deep-dive on delegating focus across shadow boundaries walks through the focus half end to end, and the deep-dive on exposing ARIA semantics with ElementInternals covers the semantics half.
For composite widgets — a listbox, a menu, a combobox — the hard problem is the active-descendant relationship. A standard listbox keeps DOM focus on a single container and points aria-activedescendant at the id of the currently highlighted option. When the options live inside a shadow root and the input lives in the light DOM (or in a different component), the IDREF cannot resolve across the boundary. The pragmatic pattern today is to keep both the focusable container and the options inside the same shadow root so the IDREF stays in one tree scope:
class OptionList extends HTMLElement {
#root; #internals; #active = 0;
constructor() {
super();
this.#root = this.attachShadow({ mode: 'open', delegatesFocus: true });
this.#internals = this.attachInternals();
this.#internals.role = 'listbox';
this.#internals.ariaLabel = 'Options';
this.#root.innerHTML = `
<style>
[role="option"][aria-selected="true"] { background: var(--hl, #1a2a55); }
:host(:focus-within) #box { outline: 2px solid var(--focus-ring, #5a7bff); }
</style>
<div id="box" tabindex="0">
<div role="option" id="opt-0">Alpha</div>
<div role="option" id="opt-1">Bravo</div>
<div role="option" id="opt-2">Charlie</div>
</div>`;
const box = this.#root.querySelector('#box');
// activedescendant points at an id in THIS shadow root — same tree scope.
box.setAttribute('aria-activedescendant', 'opt-0');
box.addEventListener('keydown', (e) => this.#move(e, box));
}
#move(e, box) {
const opts = [...this.#root.querySelectorAll('[role="option"]')];
if (e.key === 'ArrowDown') this.#active = Math.min(opts.length - 1, this.#active + 1);
else if (e.key === 'ArrowUp') this.#active = Math.max(0, this.#active - 1);
else return;
e.preventDefault();
opts.forEach((o, i) => o.setAttribute('aria-selected', String(i === this.#active)));
box.setAttribute('aria-activedescendant', opts[this.#active].id);
}
}
customElements.define('option-list', OptionList);
Common Failure Modes & Debugging Steps
-
The host is unfocusable and unannounced. A bare custom element wrapping an
<input>in a shadow root receives neither focus nor a role. Root cause: a custom element with notabindexand nodelegatesFocusis not in the tab order, and an element with no default role is exposed as a generic group. Fix: adddelegatesFocus: true(or a hosttabindex) and setinternals.role. Verify in the DevTools Accessibility pane that the node shows the intended role and is reachable by Tab. -
Host ARIA attributes silently disappear on re-render. Writing
this.setAttribute('role', 'slider')from inside the component works until a consumer overwrites it, or until framework rendering clobbers host attributes. Root cause: host attributes are shared, observable state that anyone can change. Fix: set defaults oninternalsinstead; they live on a private object the consumer cannot reach, yet still yield to a consumer’s explicit hostaria-*. -
aria-activedescendantpoints at nothing. A combobox input in the light DOM references option ids inside a shadow root and the screen reader announces nothing. Root cause: IDREF relationships do not cross tree scopes. Fix: co-locate the controller and options in one shadow root (shown above), or adopt the Reference Target proposal where supported (below). -
Focus ring appears on mouse click. Styling
.track:focusdraws a ring even when the user clicked with a pointer. Root cause::focusmatches all focus, including mouse focus. Fix: style:focus-visible, which the engine matches only for keyboard and programmatic focus heuristically.
Debugging Pitfall: The DevTools Accessibility pane shows the computed accessibility node, which already resolves internals defaults against host attribute overrides. If a role looks wrong, check the host element’s attributes first — a stray role or aria-* on the host beats your internals default by design, and the pane will not flag that the value came from the consumer rather than your component.
Debugging Pitfall: delegatesFocus only forwards to the first focusable descendant in the flattened tree. If your shadow root contains a non-interactive focusable node — a tabindex="-1" wrapper used for scroll management, say — before the real control, focus may land there instead of on the input you expected. Reorder the DOM so the intended target comes first, or override focus() to target it explicitly, and confirm with shadowRoot.activeElement which node actually received focus.
The Cross-Root Reference Problem & Reference Target
The IDREF restriction is significant enough that the platform is standardizing a fix. The Reference Target proposal lets a shadow host declare an internal element as the target of cross-root ARIA references. A consumer writes aria-activedescendant, aria-controls, or a for/label relationship against the host, and the host forwards it to a designated element inside its shadow root via shadowRoot.referenceTarget and element-reflecting ARIAMixin properties such as internals.ariaActiveDescendantElement. This preserves encapsulation — internal ids never leak — while making the relationship resolvable. Until it ships broadly, the co-location pattern above and element-reflecting ARIAMixin properties (which take element references rather than id strings) are the production-safe substitutes.
To make the constraint concrete, consider a combobox where the text input and the popup listbox are authored as two separate custom elements. A standard combobox keeps DOM focus on the input and sets aria-activedescendant to the id of the highlighted option so the screen reader announces the active option without moving focus. If the input is <my-combobox> and the options live inside <my-listbox>'s shadow root, the input’s aria-activedescendant value is an id string resolved in the input’s tree scope — it can never see an id inside the listbox’s shadow root, so the relationship silently fails and the screen reader announces nothing as the user arrows through options. There are three production responses, in order of preference: co-locate the input and options in one shadow root so the IDREF stays in a single tree scope; use element-reflecting ARIAMixin properties (ariaActiveDescendantElement and friends) which accept an element reference and, where supported, resolve across the boundary; or adopt Reference Target once it ships. What you must not do is hoist internal ids into the light DOM to make the IDREF resolve, because that breaks encapsulation and couples consumers to your internal structure.
Tabindex, Sequential Navigation, and Inert
Sequential focus navigation — the order Tab and Shift+Tab walk through the page — is computed over the flattened tree, so shadow content participates as if composed into its slot position. A custom element contributes a focus stop in three ways: it is natively focusable (it never is, since custom elements have no implicit tabindex), it carries an explicit tabindex, or its shadow root delegates focus. Choose deliberately. A tabindex="0" host is a single Tab stop and the host itself receives focus; delegatesFocus is also a single Tab stop but forwards into the shadow tree; and tabindex="-1" makes the host focusable programmatically but skips it in the Tab sequence, which is what composite widgets use for their inactive items.
For composite widgets the correct model is roving tabindex: exactly one descendant has tabindex="0" at a time and all siblings have tabindex="-1", with arrow keys moving the 0 and calling focus() on the newly active item. This keeps the entire widget a single Tab stop while letting arrow keys navigate within it, matching the WAI-ARIA Authoring Practices for menus, grids, and toolbars. delegatesFocus is deliberately not used here because it always targets the first focusable descendant, which fights the roving model.
inert complements focus management by removing a region from both the tab order and the accessibility tree. Marking the page background inert while a component’s dialog is open is the modern replacement for manual focus trapping, and because it acts on the flattened tree it correctly hides slotted and shadow content. Restoring focus to the element that opened the dialog after it closes — typically saved before opening and re-focused in a teardown step — completes the keyboard contract.
Framework Interop
React (through version 18) sets unknown props as attributes and forwards tabIndex; pass tabIndex={0} or rely on delegatesFocus so the host is reachable, and set aria-*/role on the JSX host only when you intend to override component defaults. React 19 improves custom-element prop handling but does not change the accessibility-tree rules. Vue binds aria-* and role as attributes naturally and templates with named slots compose into the flattened tree as expected. Angular needs CUSTOM_ELEMENTS_SCHEMA to allow the element and binds [attr.aria-label] and [attr.role] as host overrides. For SSR, default semantics set in the constructor exist only after upgrade; if you server-render markup, the role is absent until hydration, so mirror critical roles into the serialized markup or accept a brief gap, coordinating with the Form-Associated Custom Elements lifecycle when the same component also participates in forms.
Performance & Memory Implications
Setting properties on ElementInternals is cheap and does not mutate the DOM, so there is no attribute-mutation observer churn and no reflow from semantics updates — a meaningful advantage over toggling host aria-* attributes on every state change. Focus delegation adds no measurable cost; it is resolved inside the engine’s existing focus algorithm. The one leak vector is keyboard listeners: attach them with an AbortController signal and call controller.abort() in disconnectedCallback so a removed component does not retain keydown handlers. inert is essentially free and is far cheaper than manually saving and restoring tabindex across a subtree to trap focus.
Browser Compatibility & Polyfill Strategy
delegatesFocus and :focus-visible are supported across Chrome, Edge, Firefox, and Safari and need no polyfill in evergreen browsers. ElementInternals with ARIAMixin reflection shipped in Chromium 90+, Firefox 119+, and Safari 16.4+; older engines silently lack the internals.role/aria* setters, so feature-detect with 'role' in ElementInternals.prototype and fall back to setting host aria-* attributes when absent. The inert attribute is supported in Chrome 102+, Firefox 112+, and Safari 15.5+, with a well-maintained WICG polyfill for older targets. The Reference Target proposal is not yet shipping in any stable browser; treat it as forward-looking and use co-location today.
| Feature | Chrome/Edge | Firefox | Safari | Fallback |
|---|---|---|---|---|
delegatesFocus |
53+ | 94+ | 10+ | host tabindex + manual focus() forwarding |
:focus-visible |
86+ | 85+ | 15.4+ | :focus plus a pointer-detection class |
ElementInternals ARIAMixin |
90+ | 119+ | 16.4+ | host role/aria-* attributes |
inert |
102+ | 112+ | 15.5+ | WICG inert polyfill |
The degradation contract is straightforward: in the worst supported engine, a component must still render as a labeled, focusable control. That means a host tabindex so it is reachable, host aria-* attributes set as a fallback so it is announced, and :focus styling guarded by a pointer-detection class so the ring is acceptable. Each newer API then upgrades the experience — delegatesFocus collapses a host-plus-control double Tab stop into one, internals defaults remove the public-attribute fragility, :focus-visible refines the ring, and inert replaces manual focus trapping — without ever being load-bearing for basic operability.
Related
- Form-Associated Custom Elements — shares
ElementInternals; form and accessibility state are set on the same object. - Shadow DOM Construction & Modes — the boundary and
delegatesFocusoption originate atattachShadow(). - Delegating Focus Across Shadow Boundaries — the focus half of this topic, end to end.
- Exposing ARIA Semantics with ElementInternals — the semantics half, with override precedence.
- Core Architecture & Lifecycle Management — the parent section governing constructor-phase decisions.