Form-Associated Custom Elements
Guide to making web components participate in native HTML form submission using ElementInternals.
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:
- Submit values via
FormDatawithout 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.
<h3>1. <code>static formAssociated = true</code></h3> <p>Tells the browser this element participates in forms. Without this, <code>attachInternals()</code> won't provide form-related methods.</p> <h3>2. <code>attachInternals()</code></h3> <p>Returns an <code>ElementInternals</code> object. Call this in the <code>constructor()</code>. It provides <code>setFormValue()</code>, <code>setValidity()</code>, and ARIA reflection.</p> <h3>3. <code>setFormValue(value)</code></h3> <p>Sets the value submitted with the form. Pass <code>null</code> to exclude the element from form data. The optional second argument is a "state" string used by <code>formStateRestoreCallback</code>.</p> <h3>4. <code>setValidity()</code></h3> <p>Sets constraint validation state. Pass an empty object <code>{}</code> to mark as valid.</p> <h3>5. Lifecycle Callbacks</h3> <p><code>formResetCallback()</code> fires when the parent form resets. <code>formStateRestoreCallback(state)</code> fires when the browser restores form state from navigation history.</p></section> <section> <h2>Progressive Enhancement</h2> <p>For components that need a no-JS fallback, include a hidden input or native control that works before JavaScript loads:</p> <code-block language="html" show-lines label="Progressive enhancement" data-escape><!-- 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:
<h3>CSS Validation Selectors</h3> <p>VB's <code>:user-valid</code> and <code>:user-invalid</code> pseudo-classes work automatically with form-associated custom elements:</p> <code-block language="css" show-lines label="CSS validation" data-escape>/* 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-error);}
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).