Make web components participate in native HTML form submission, validation, and reset using the ElementInternals API.
What Are Form-Associated Custom Elements?
By default, custom elements are invisible to HTML forms. A <form> only collects data from native form controls (<input>, <select>, <textarea>). Form-Associated Custom Elements (FACE) let your web components behave like native form controls:
Submit values via FormData without hidden inputs
Participate in native constraint validation (:valid, :invalid)
Respond to form reset and browser history restoration
Work with <label> and <fieldset>
Complete Template
Here is a complete annotated template showing all the pieces needed for a form-associated web component:
class MyInput extends HTMLElement {
static formAssociated = true;
#internals;
#initialValue = '';
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
// Set up your component's DOM and behavior
this.#initialValue = this.getAttribute('value') || '';
this.#syncFormValue(this.#initialValue);
this.#validate();
}
// Called when the parent form is reset
formResetCallback() {
this.#syncFormValue(this.#initialValue);
this.#validate();
}
// Called when the browser restores form state (back/forward)
formStateRestoreCallback(state) {
if (state) {
this.#syncFormValue(state);
this.#validate();
}
}
#syncFormValue(value) {
if (value) {
this.#internals.setFormValue(value);
} else {
this.#internals.setFormValue(null);
}
}
#validate() {
if (this.hasAttribute('required') && !this.value) {
this.#internals.setValidity(
{ valueMissing: true },
'Please fill out this field',
this.querySelector('input') // anchor element for popup
);
} else {
this.#internals.setValidity({});
}
}
get value() {
// Return current value from your component's state
}
set value(val) {
// Update component state
this.#syncFormValue(val);
this.#validate();
}
}
customElements.define('my-input', MyInput);
Step-by-Step Checklist
The boilerplate is intentionally small — roughly 5 lines to opt in. The rest is component-specific logic.
// 1. Opt in to form association
static formAssociated = true;
// 2. Attach internals in constructor
constructor() {
super();
this.#internals = this.attachInternals();
}
// 3. Sync value to form
this.#internals.setFormValue(value);
// 4. Set validation state
this.#internals.setValidity({ valueMissing: true }, 'Message', anchor);
this.#internals.setValidity({}); // clear errors
// 5. Implement lifecycle callbacks
formResetCallback() { /* handle form reset */ }
formStateRestoreCallback(state) { /* handle back/forward */ }
1. static formAssociated = true
Tells the browser this element participates in forms. Without this, attachInternals() won't provide form-related methods.
2. attachInternals()
Returns an ElementInternals object. Call this in the constructor(). It provides setFormValue(), setValidity(), and ARIA reflection.
3. setFormValue(value)
Sets the value submitted with the form. Pass null to exclude the element from form data. The optional second argument is a "state" string used by formStateRestoreCallback.
4. setValidity()
Sets constraint validation state. Pass an empty object {} to mark as valid.
5. Lifecycle Callbacks
formResetCallback() fires when the parent form resets. formStateRestoreCallback(state) fires when the browser restores form state from navigation history.
Progressive Enhancement
For components that need a no-JS fallback, include a hidden input or native control that works before JavaScript loads:
<!-- Progressive enhancement pattern -->
<my-input name="color">
<!-- Hidden input works before JS loads -->
<input type="hidden" name="color" value="default">
<select name="color">
<option value="red">Red</option>
<option value="blue">Blue</option>
</select>
</my-input>
<style>
/* Before JS: show native fallback */
my-input:not(:defined) select { display: block; }
my-input:not(:defined) input[type="hidden"] { /* kept for form submission */ }
/* After JS: component takes over form submission via ElementInternals */
my-input:defined select { display: none; }
my-input:defined input[type="hidden"] { display: none; }
</style>
VB components like <combo-box> use a different strategy: the light-DOM input and list are usable before JS, and JS enhances them with filtering, keyboard navigation, and form association.
Validation
The setValidity() method accepts the same constraint flags as native inputs:
// Validation flags match native constraint validation API
this.#internals.setValidity(
{
valueMissing: true, // required but empty
typeMismatch: true, // wrong format
patternMismatch: true, // doesn't match pattern
tooLong: true, // exceeds maxlength
tooShort: true, // below minlength
rangeUnderflow: true, // below min
rangeOverflow: true, // above max
stepMismatch: true, // doesn't match step
customError: true, // custom validation
},
'Human-readable error message',
anchorElement // element to position the validation popup near
);
// Clear all validation errors
this.#internals.setValidity({});
CSS Validation Selectors
VB's :user-valid and :user-invalid pseudo-classes work automatically with form-associated custom elements:
/* VB's validation CSS works with ElementInternals */
my-input:user-valid {
/* Styles when component value is valid */
}
my-input:user-invalid {
/* Styles when component value is invalid */
outline: 2px solid var(--color-danger);
}
Firefox ARIA Caveat
Firefox does not yet support ARIA reflection on ElementInternals. When you set internals.role or internals.ariaLabel, Firefox silently ignores it. VB provides a small utility to handle this cross-browser:
import { setRole, setAriaProperty } from '../../utils/form-internals.js';
// In connectedCallback:
setRole(this, this.#internals, 'combobox');
setAriaProperty(this, this.#internals, 'expanded', 'false');
setAriaProperty(this, this.#internals, 'label', 'Pick a color');
These helpers check for support and fall back to setting attributes on the host element. See src/utils/form-internals.js.
VB Examples
Two VB components use this pattern:
<star-rating> — Star rating input with radio buttons internally, form value via ElementInternals
<combo-box> — Autocomplete combobox with full keyboard navigation, filtering, and form association
Browser Support
Form-Associated Custom Elements are supported in all modern browsers. ElementInternals shipped in Chrome 77, Firefox 93, and Safari 16.4. The ARIA reflection part of ElementInternals is not yet supported in Firefox (use the VB utility above).