Skip to content

ElementInternals

This lesson explains the ElementInternals API, how to attach it in custom elements, and how it powers form values, validation, labels, and accessible semantics.

What Is ElementInternals?

ElementInternals is a browser API that gives custom elements access to platform-level behavior normally reserved for native form controls and interactive elements. You obtain it by calling this.attachInternals() in the constructor.

Through ElementInternals, a component can set its submitted form value, report validation state, expose ARIA role and states on the host, and access its associated form and labels.

Capability API
Attach internals attachInternals()
Form value setFormValue()
Validation setValidity()
Associated form internals.form
Labels internals.labels
ARIA semantics internals.role, internals.states

Basic ElementInternals Setup

class AppTextField extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.attachShadow({ mode: "open" });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML =
      `<input part="input" type="text" />`;

    this.input = this.shadowRoot.querySelector("input");
    this.input.addEventListener("input", () => {
      this.internals.setFormValue(this.input.value);
    });
  }
}

customElements.define("app-text-field", AppTextField);
<form>
  <app-text-field name="username"></app-text-field>
  <button type="submit">Save</button>
</form>

attachInternals() must run in the constructor. Store the returned object on this.internals and use it for all platform integrations.

attachInternals() Rules

  • Call it exactly once per element instance.
  • Call it only inside the constructor().
  • Call it after super().
  • Use static formAssociated = true for full form APIs.
constructor() {
  super();
  this.internals = this.attachInternals();
}

Calling attachInternals() outside the constructor throws an error. Each custom element instance gets its own internals object.

setFormValue()

// String value for text-like controls
this.internals.setFormValue("alex");

// Null for unchecked checkbox-like controls
this.internals.setFormValue(null);

// File for file inputs
this.internals.setFormValue(file);

// Value plus restoration state
this.internals.setFormValue(value, state);

setFormValue() tells the browser what the control should contribute to FormData and form submission. Update it whenever the user changes the control value.

Form and Label Properties

const form = this.internals.form;
const labels = this.internals.labels;

console.log(form?.id);
console.log(labels.length);
Property Returns
internals.form The associated <form> or null
internals.labels NodeList of associated <label> elements
internals.willValidate Whether the control participates in validation
internals.validity Current ValidityState object
internals.validationMessage Current validation error message

setValidity() Overview

this.internals.setValidity(
  { valueMissing: true },
  "This field is required.",
  this.input
);
// Clear errors when valid
this.internals.setValidity({});

setValidity() reports validation state to the parent form. The host element can then participate in checkValidity() and reportValidity() like a native input. See the Form Validation lesson for full patterns.

Validation Methods on the Host

const isValid = element.checkValidity();
const reported = element.reportValidity();

console.log(element.validity.valid);
console.log(element.validationMessage);

Once internals manage validity, the custom element itself exposes the same validation methods and properties developers expect from native form controls.

ARIA Role with ElementInternals

constructor() {
  super();
  this.internals = this.attachInternals();
  this.internals.role = "switch";
}

Setting internals.role exposes semantic role information on the host element. This is especially useful for Shadow DOM components where the meaningful interactive structure lives inside the shadow tree.

ARIA States with internals.states

toggleChecked(checked) {
  if (checked) {
    this.internals.states.add("checked");
  } else {
    this.internals.states.delete("checked");
  }
}

setDisabled(disabled) {
  if (disabled) {
    this.internals.states.add("disabled");
  } else {
    this.internals.states.delete("disabled");
  }
}

internals.states is a CustomStateSet that reflects ARIA states on the host, such as checked, disabled, and expanded. Assistive technologies can read these states even when the visible control is inside Shadow DOM.

Accessible Switch Example

class AppSwitch extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.internals.role = "switch";
    this.attachShadow({ mode: "open" });
    this._checked = false;
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <button type="button" part="button">
        <slot>Toggle</slot>
      </button>
    `;

    this.button = this.shadowRoot.querySelector("button");
    this.button.addEventListener("click", () => this.toggle());
    this.syncState();
  }

  toggle() {
    this._checked = !this._checked;
    this.syncState();
    this.internals.setFormValue(this._checked ? "on" : null);
  }

  syncState() {
    this.button.setAttribute("aria-checked", String(this._checked));
    this._checked
      ? this.internals.states.add("checked")
      : this.internals.states.delete("checked");
  }
}

ElementInternals and Shadow DOM

Shadow DOM hides internal structure, but form and accessibility APIs must still apply to the host. ElementInternals bridges that gap by letting the host act as the public control surface.

  • Internal input changes update setFormValue().
  • Validation anchors can point to internal focusable nodes.
  • ARIA role and states live on the host through internals.
  • Parent forms interact with the custom tag, not shadow nodes.

ElementInternals API Summary

Member Purpose
attachInternals() Create the internals object in the constructor
setFormValue(value, state?) Set submitted and restoration values
setValidity(flags, message?, anchor?) Report valid or invalid state
form Read associated form
labels Read associated labels
role Set host ARIA role
states Manage host ARIA states
validity Read current validity flags
validationMessage Read current error message

Relationship to Form-Associated Elements

Many ElementInternals features require static formAssociated = true. Without it, some form value and validation behavior is unavailable. See Form Associated Custom Elements for the full form lifecycle.

class AppCheckbox extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
  }
}

Full ElementInternals Form Control

class AppSelect extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.internals.role = "combobox";
    this.attachShadow({ mode: "open" });
    this._value = "";
  }

  connectedCallback() {
    this.render();
    this.shadowRoot.addEventListener("click", (event) => {
      const option = event.target.closest("[data-value]");
      if (!option) return;
      this.selectValue(option.dataset.value);
    });
  }

  selectValue(value) {
    this._value = value;
    this.internals.setFormValue(value);
    this.internals.setValidity({});
    this.internals.states.toggle("expanded", false);
    this.render();
  }

  validate() {
    const missing = !this._value;
    this.internals.setValidity(
      { valueMissing: missing },
      missing ? "Choose an option." : ""
    );
  }
}

When to Use ElementInternals

  • You build custom inputs, toggles, or pickers with Shadow DOM.
  • The control must submit values through real HTML forms.
  • You need native validation integration.
  • You must expose ARIA semantics on the host element.
  • You want label and form association without leaking internals.
  • You are building accessible design system form primitives.

ElementInternals Best Practices

  • Attach internals once in the constructor and reuse this.internals.
  • Keep form value updates in sync with visible UI state.
  • Use internals.role and internals.states for accessibility.
  • Clear validity with an empty flags object when the field becomes valid.
  • Expose stable host-level behavior; hide shadow implementation details.
  • Document value format, validation rules, and ARIA behavior.
  • Test with real forms, labels, reset, and disabled fieldsets.

Common ElementInternals Mistakes

  • Calling attachInternals() outside the constructor.
  • Forgetting formAssociated = true for form controls.
  • Updating UI without calling setFormValue().
  • Setting ARIA only on internal nodes and not on the host.
  • Using setValidity() without meaningful error messages.
  • Assuming name alone submits a value without internals.
  • Storing unrelated app state on the internals object conceptually.

Key Takeaways

  • ElementInternals connects custom elements to platform APIs.
  • attachInternals() must run in the constructor.
  • setFormValue() powers form submission for custom controls.
  • setValidity() integrates with native validation flows.
  • role and states improve accessibility for Shadow DOM widgets.
  • ElementInternals is the bridge between encapsulated UI and browser behavior.

Pro Tip

Think of ElementInternals as the host element's platform interface. Internal shadow markup can change freely as long as form values, validity, and ARIA semantics stay consistent on the host.