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.
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.