ElementInternals API: Making Custom Elements Truly Form-Capable
AI generated
JS
() =>
JavaScript · Web Components · Forms
ElementInternals API
Making custom elements just as form-capable as a native input element

Without ElementInternals, a hand-built Custom Element could never truly participate in a form element: no automatic entry in FormData, no native validation, no :invalid styling. The ElementInternals API closes that gap through attachInternals(), setFormValue(), and setValidity(), turning an ordinary Custom Element into a full-fledged form control.

15 min read attachInternals() · setFormValue() Forms · validation · accessibility

1. The form problem with classic Custom Elements

A Custom Element without any special provisions is simply invisible to a surrounding form element. Its value does not automatically land in a FormData object, it does not participate in native Constraint Validation, and CSS pseudo-classes like :invalid or :required have no effect, because the browser simply does not recognize the element as a form control, no matter how much it visually resembles an input.

The previously common solution was to create a hidden native input element inside the Custom Element and keep its value in sync, an error-prone workaround with duplicated state and extra DOM weight. The ElementInternals API replaces that trick with a direct, native connection from the Custom Element itself to the browser's form infrastructure.

2. attachInternals(): the bridge between a Custom Element and a form

The first step is the static property static formAssociated = true on the element class, which signals to the browser that a Custom Element wants to participate in forms. Only after that does this.attachInternals() inside the constructor return an ElementInternals object, which serves as the central interface for all subsequent form operations and is typically stored once in a private instance field.

attachInternals() may only be called once per element, a second call throws an error. From that point on, the browser automatically recognizes the element as part of the surrounding form element, including inclusion in the form.elements collection, without any additional native element needed in the DOM.


class RatingField extends HTMLElement {
  static formAssociated = true;

  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals(); // allowed only once per element
  }
}

customElements.define('rating-field', RatingField);

3. setFormValue(): setting the value that gets submitted

The method internals.setFormValue(value) determines which value gets entered into a FormData object under the Custom Element's name attribute when the form is submitted. The parameter can be a plain string, a File object, or an entire FormData object, which also allows more complex controls such as a file upload or a multi-select element, without having to juggle multiple name attributes.

setFormValue() optionally accepts a second parameter, state, which is stored separately from the submitted value and can be restored on form reset via formStateRestoreCallback. That is useful, for instance, for a control whose visible display state differs from the actual data representation, such as a star rating that internally manages both the number and the intermediate hover states.


set value(newValue) {
  this.#value = newValue;
  // value gets submitted when the surrounding form is submitted
  this.#internals.setFormValue(String(newValue));
}

get value() {
  return this.#value;
}

4. setValidity(): plugging custom rules into the Constraint Validation API

internals.setValidity(flags, message, anchor) registers the Custom Element with the browser's native Constraint Validation API, using the same state flags that native input also uses, such as valueMissing, tooShort, or a custom customError. As soon as a flag is set, the element counts as invalid, and form.reportValidity() automatically shows the given message as a bubble anchored at the given anchor position.

Calling internals.setValidity({}) with no flags marks the element valid again. Because this validation is part of the very same API that native elements use, CSS selectors like :invalid, :valid, or :user-invalid work on the Custom Element just as reliably as on a regular input element, with no manual class assignment in JavaScript required.


#validate() {
  if (this.hasAttribute('required') && !this.#value) {
    this.#internals.setValidity(
      { valueMissing: true },
      'Please choose a rating.',
      this.#star1 // anchor element for the validation bubble
    );
    return;
  }
  this.#internals.setValidity({}); // valid again
}

5. Custom CSS states with internals.states and the :state() pseudo-class

Through internals.states, a CustomStateSet, a Custom Element can define its own custom states, such as checked or pressed, which can be targeted from outside via the :state(checked) CSS pseudo-class, without needing a class or a data attribute for that at all.

The advantage over a classic CSS class name is true encapsulation: a state set via internals.states.add('checked') can only be read from outside through the :state() selector syntax, never through classList or getAttribute, which prevents external code from accidentally manipulating the internal display state directly instead of using the element's public API.


// Set the state as soon as the user picks a rating
this.#internals.states.add('checked');
this.#internals.states.delete('checked');

/* Matching CSS outside the component:
rating-field:state(checked) .star { color: #ca8a04; }
*/

6. Accessibility: ARIA reflection through ElementInternals

ElementInternals exposes a whole set of ARIA reflection properties, such as internals.role, internals.ariaLabel, or internals.ariaRequired, letting a Custom Element define its semantic role without requiring developers who use the element to manually add matching aria attributes in the markup.

That is especially valuable because it anchors responsibility for correct accessibility semantics where it belongs, in the component's own implementation, instead of putting it on every single place the element gets used. A screen reader reliably recognizes the element for what it actually is, for example as a radiogroup or a slider, regardless of the chosen HTML tag name.

7. Reset, disabled inheritance, and further form callbacks

Besides setFormValue and setValidity, the form-associated Custom Element specification defines a set of lifecycle callbacks: formResetCallback gets called when the surrounding form is reset, formDisabledCallback informs the element when a surrounding fieldset is disabled, and formStateRestoreCallback restores saved state during browser navigation.

These callbacks ensure that a form-associated Custom Element behaves exactly like a native element in edge cases such as form resets or nested disabled fieldsets, without the component itself having to listen for events like reset on the surrounding form, which would be error-prone as soon as the element is removed from the DOM and re-inserted.

8. Complete example: a form-capable star rating

A star rating works well as a running example because it needs to both submit a numeric value and support required-field validation. The class combines formAssociated, attachInternals inside the constructor, setFormValue on every rating change, and setValidity to mark an empty rating as valueMissing once the required attribute is set.

The example additionally uses internals.states to set the hover state while the mouse pointer is over a star, so that CSS alone via :state(hover) can color the preview stars, with no extra class manipulation inside the event handler. The result behaves in every respect like a native form control.


class StarRating extends HTMLElement {
  static formAssociated = true;
  #internals;
  #value = 0;

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

  setStar(number) {
    this.#value = number;
    this.#internals.setFormValue(String(number));
    this.#checkValidity();
  }

  #checkValidity() {
    if (this.hasAttribute('required') && this.#value === 0) {
      this.#internals.setValidity({ valueMissing: true }, 'Please rate this item.');
    } else {
      this.#internals.setValidity({});
    }
  }
}
customElements.define('star-rating', StarRating);

9. Limitations of the API and comparison with native form elements

ElementInternals makes a Custom Element form-capable, but it does not automatically replace every keyboard interaction that native elements bring for free, such as toggling a checkbox with the space bar. Such interactions still need to be implemented explicitly through event listeners, since the API only takes care of the form connection, not keyboard semantics.

In older browsers without support for ElementInternals, such a Custom Element drops out of form submission entirely, so feature detection via 'attachInternals' in HTMLElement.prototype before production use is recommended, with a hidden native input as a fallback for the rare case of missing support.

Feature Native input Custom Element + ElementInternals Custom Element without Internals
Entry in FormData Automatic Automatic via setFormValue Completely missing
Native validation Built in Reproduced via setValidity Not available
CSS pseudo-classes :invalid/:required Work Work via setValidity Do not work
Inclusion in form.elements Automatic Automatic after attachInternals Completely missing

Mironsoft

Modern browser APIs, performance, and maintainable JavaScript

JavaScript that holds up in the real browser, not just in the tutorial?

We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.

Code Review

Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.

Performance Optimization

Improving bundle size, load time, and runtime performance with modern APIs.

Modernization

Deliberately introducing native browser APIs instead of heavy libraries.

10. Summary

ElementInternals API: The Key Facts at a Glance

Core idea

attachInternals() connects a Custom Element directly to the browser's native form infrastructure.

Submitting a value

setFormValue() determines what lands in FormData under the name attribute on submit.

Validation

setValidity() uses the same flags as native elements and activates :invalid automatically.

Key limitation

Keyboard interactions like the space bar for checkboxes still need to be implemented manually.

11. FAQ: ElementInternals API: The Key Facts at a Glance

1What does static formAssociated = true do?
It signals to the browser that the Custom Element wants to participate in forms, a prerequisite for attachInternals to provide a working form connection at all and for the element to later appear in form.elements.
2How many times may attachInternals be called?
Only once per element, usually inside the constructor. A second call throws an error, so the returned ElementInternals object is typically stored once in a private instance field and reused afterward.
3What is the difference between the value and the state parameter of setFormValue?
The first parameter is the value actually submitted, while the optional second parameter is internal state that can be restored via formStateRestoreCallback on browser reset and may differ from the submitted value.
4Do I need to call setValidity again on every value change?
Yes, validity is not a state set once, it must be re-evaluated on every relevant change and updated via setValidity, otherwise an element stays incorrectly marked as valid or invalid.
5What is internals.states for?
internals.states is a CustomStateSet that lets an element define its own custom states like checked or pressed, which can only be read from outside via the :state() CSS pseudo-class, without needing class or data attributes.
6Can I also set accessibility properties with ElementInternals?
Yes, properties like internals.role or internals.ariaLabel let you define ARIA semantics directly in the component implementation, so users of the component no longer need to add manual aria attributes.
7Does my Custom Element automatically react to a form reset?
Only if formResetCallback is implemented. The browser calls this callback automatically on a form reset, but the actual reset logic, such as restoring a default value, must be written inside the callback itself.
8What happens when a surrounding fieldset gets disabled?
The browser calls formDisabledCallback with a boolean parameter, and the Custom Element must react to it itself, for example by locking interactions and showing itself as disabled visually, this does not happen automatically without this callback.
9Do I still need a hidden input element as a fallback?
Not in modern environments anymore, but for projects that need to support very old browsers, a feature-detected fallback with a hidden input is still a sensible safeguard so form functionality does not fail completely.
10Does ElementInternals also replace keyboard interactions like toggling with the space bar?
No, the API only takes care of connecting to the form infrastructure and validation, all keyboard and pointer interactions still need to be implemented explicitly through their own event listeners.