Form-Associated Custom Elements

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:

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 <combobox-wc> 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:

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

Further Reading