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.
Table of Contents
- 1. The form problem with classic Custom Elements
- 2. attachInternals(): the bridge between a Custom Element and a form
- 3. setFormValue(): setting the value that gets submitted
- 4. setValidity(): plugging custom rules into the Constraint Validation API
- 5. Custom CSS states with internals.states and the :state() pseudo-class
- 6. Accessibility: ARIA reflection through ElementInternals
- 7. Reset, disabled inheritance, and further form callbacks
- 8. Complete example: a form-capable star rating
- 9. Limitations of the API and comparison with native form elements
- 10. Summary
- 11. FAQ
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.