Exposing ARIA Semantics with ElementInternals

A custom element with no role is announced by assistive technology as a generic, meaningless container, and setting role and aria-* on the host instead leaves your semantics at the mercy of whatever attributes a consumer writes. The fix is to set default semantics through ElementInternals so the component is accessible out of the box while consumer overrides still win where they should.

This is the semantics counterpart to focus delegation within Accessibility & Focus Management: get both right and a component is fully operable; get only one and it is still broken for some users.

Minimal Reproducible Example

The element below is a custom slider that renders a working visual track but ships no semantics. To a screen reader it is an unlabeled group with no role, value, or range — effectively invisible as a control.

class VolumeSlider extends HTMLElement {
  #root;
  constructor() {
    super();
    this.#root = this.attachShadow({ mode: 'open' });
    this.#root.innerHTML = `
      <style>:host { display: inline-block; width: 160px; }</style>
      <div class="track" part="track"></div>`;
  }
}
customElements.define('volume-slider', VolumeSlider);

The instinctive fix is to set ARIA on the host from inside the component:

connectedCallback() {
  // Anti-pattern: host attributes are public, shared state.
  this.setAttribute('role', 'slider');
  this.setAttribute('aria-valuemin', '0');
  this.setAttribute('aria-valuemax', '100');
  this.setAttribute('aria-valuenow', '50');
}
<!-- A consumer (or a framework re-render) overwrites your semantics: -->
<volume-slider role="presentation"></volume-slider>
<!-- ...or simply clobbers aria-valuenow on every render,
     and your component has no way to defend its defaults. -->

Two failures coexist here. The first version has no semantics. The second has semantics that live on public host attributes, so a consumer’s role="presentation", a framework that controls host attributes, or an accidental overwrite silently destroys them — and the component cannot tell whether the consumer meant it.

Root-Cause Analysis

ARIA attributes on the host element are ordinary DOM attributes: globally visible, writable by anyone, and subject to being managed by whatever framework renders the host. When a component sets them on itself, it is competing for the same slot the consumer uses, with no precedence rule to arbitrate. There is no concept of “my default that yields to your explicit choice” — last writer wins, which is fragile.

The platform’s answer is the ARIAMixin interface exposed on ElementInternals, obtained via this.attachInternals(). Per the WHATWG HTML Standard and the Accessibility Object Model work, properties such as internals.role, internals.ariaLabel, internals.ariaValueNow, and internals.ariaExpanded set the element’s default (implicit) semantics. These values live on a private object the consumer cannot reach. During accessibility-tree computation the engine resolves them with lower priority than a matching attribute on the host: if the consumer writes role="…" or aria-* on the element, that value wins; otherwise the internals default is used. This is exactly the “sensible default, overridable by the consumer” contract the host-attribute approach cannot express. Because the same ElementInternals object also carries form state, this composes directly with Form-Associated Custom Elements, where one internals object handles both validity and semantics.

The precedence rule is the entire point, so it is worth stating in spec terms. Each ARIAMixin property on ElementInternals maps to a content attribute (rolerole, ariaValueNowaria-valuenow, and so on). When the accessibility tree is computed, the engine reads the content attribute first; only if it is absent does it fall back to the internals value. This is the inverse of the intuition many authors start with — they assume “I set it explicitly in code, so it should win” — but the platform deliberately ranks the consumer’s declarative attribute above the component’s imperative default, because the consumer is the one who knows the usage context. A date-picker shipped with internals.role = 'group' can be relabeled role="application" by a consumer who needs different interaction semantics, and the component does not have to anticipate or permit that explicitly. Authors who instead set host attributes from inside the component invert this relationship: their imperative write becomes the consumer-facing attribute, leaving no defaultable layer underneath and no way for the consumer to express intent that the component will respect.

attachInternals() also enforces a useful one-time discipline. It may be called only once per element and throws if the element is not a defined custom element or if it has the internals disabled, so the natural place to call it is the constructor, storing the returned object in a private field. After that, semantics updates are plain property assignments with no DOM mutation.

Semantics precedence: internals defaults versus host attributes Default semantics set on ElementInternals are resolved with lower priority than matching host ARIA attributes, so the consumer override wins when present and the component default applies otherwise. Host attribute role="presentation" consumer-controlled · wins internals default internals.role = 'slider' private · used if no override Resolve semantics host attr beats internals default Accessibility tree computed role/name

Production-Safe Fix

Acquire ElementInternals once in the constructor and set the default role and ARIA state there. Update the dynamic state (ariaValueNow) whenever the value changes. Never touch host attributes for semantics — that surface belongs to the consumer.

class VolumeSlider extends HTMLElement {
  #root;
  #internals;
  #value = 50;

  constructor() {
    super();
    this.#root = this.attachShadow({ mode: 'open', delegatesFocus: true });
    this.#internals = this.attachInternals();

    // Feature-detect ARIAMixin; fall back to host attributes only if absent.
    if ('role' in ElementInternals.prototype) {
      this.#internals.role = 'slider';
      this.#internals.ariaValueMin = '0';
      this.#internals.ariaValueMax = '100';
      this.#internals.ariaValueNow = String(this.#value);
      this.#internals.ariaLabel = 'Volume';
    } else {
      this.setAttribute('role', 'slider');
      this.setAttribute('aria-valuemin', '0');
      this.setAttribute('aria-valuemax', '100');
      this.setAttribute('aria-valuenow', String(this.#value));
      this.setAttribute('aria-label', 'Volume');
    }

    this.#root.innerHTML = `
      <style>
        :host { display: inline-block; width: 160px; outline: none; }
        .track { height: 6px; border-radius: 3px; background: var(--track, #1a2a55); }
        .track:focus-visible { outline: 2px solid var(--focus-ring, #5a7bff); }
      </style>
      <div class="track" part="track" tabindex="0"></div>`;

    this.#root.querySelector('.track')
      .addEventListener('keydown', (e) => this.#onKey(e));
  }

  #onKey(e) {
    if (e.key === 'ArrowRight') this.value = Math.min(100, this.#value + 5);
    else if (e.key === 'ArrowLeft') this.value = Math.max(0, this.#value - 5);
  }

  set value(v) {
    this.#value = v;
    // Update default state; consumer aria-valuenow on host still overrides.
    if ('ariaValueNow' in this.#internals) this.#internals.ariaValueNow = String(v);
    else this.setAttribute('aria-valuenow', String(v));
  }
  get value() { return this.#value; }
}
customElements.define('volume-slider', VolumeSlider);

Now <volume-slider> announces as a slider with a label and live value with zero markup from the consumer. If a consumer deliberately writes role="presentation" on the host, the platform honors it — which is the correct, intentional escape hatch, not a bug. Setting delegatesFocus and a tabindex on the inner track makes the same component keyboard-operable, so the role is paired with real interactivity rather than an empty promise.

Two design choices in this code are deliberate. First, the feature detection ('role' in ElementInternals.prototype) is checked once and both the initial setup and the runtime value setter branch on the same capability, so a single environment never mixes internals semantics with host-attribute fallbacks — mixing the two would let a fallback attribute accidentally override an internals default. Second, the dynamic state (ariaValueNow) is updated in the value setter rather than recomputed on read, which means the accessibility tree reflects the live value the instant it changes, and screen readers configured to announce value changes do so immediately. A slider whose aria-valuenow lags its visual position is a classic accessibility bug; routing all writes through one setter prevents it.

Verification

Default semantics never appear as host attributes, so inspecting the element’s HTML shows nothing — that is expected. Verify through the accessibility tree instead.

const s = document.querySelector('volume-slider');

// Defaults are NOT reflected as attributes — this is by design:
console.log(s.getAttribute('role'));        // null
console.log(s.getAttribute('aria-valuenow')); // null

// They live on internals (where readable) and in the a11y tree:
console.log(s.matches('[role]'));           // false — no host attribute

// Drive the value and confirm the computed node updates:
s.value = 75;
// Open DevTools -> Elements -> Accessibility pane on <volume-slider>:
//   Computed Properties show role: slider, value: 75, name: "Volume".

// Override precedence check:
s.setAttribute('role', 'presentation');
// Accessibility pane now shows the host role winning over the internals default.

In Chrome DevTools, select the element and open the Accessibility pane: the Computed Properties section shows the resolved role, value, and accessible name even though no ARIA attributes exist in the markup. Removing the consumer’s role="presentation" restores the slider default — demonstrating the precedence rule live. In automated tests, assert against the accessibility tree (for example via getComputedAccessibleNode in supporting environments, or a testing library that reads computed roles) rather than against host attributes, which will be empty.

When to Use / When to Avoid

Situation Use ElementInternals ARIAMixin?
Shipping a reusable component that needs a default role/state Yes — defaults belong on internals
Value/state that changes at runtime (ariaValueNow, ariaExpanded) Yes — mutate the internals property, no DOM churn
You want consumers to be able to override the role Yes — internals defaults yield to host attributes
A one-off element in app code you fully control Host role/aria-* is fine; internals is optional
Cross-root relationships (aria-activedescendant to a node in another tree) No — internals defaults can’t bridge tree scopes; see Reference Target / co-location
Target engines predate ARIAMixin (older Safari/Firefox) Feature-detect and fall back to host attributes
You need the value to appear as a real attribute for non-AT tooling Host attributes (or reflect both) — internals state is invisible to attribute selectors

Avoid internals semantics where you specifically need attribute-based hooks: CSS attribute selectors, querySelector by [aria-*], or third-party tooling that reads markup will not see internals defaults, because they are intentionally absent from the DOM.