Form-Associated Custom Elements
A form-associated custom element is a custom element that the browser treats as a genuine form control: it submits a value, participates in constraint validation, resets with the form, and restores state on navigation. The capability is unlocked by a single static flag and a connection to the ElementInternals object, turning an otherwise inert <my-input> into a peer of <input> and <select>.
This guide sits within Core Architecture & Lifecycle Management and treats form association as a distinct lifecycle: a custom element opts in at definition time, attaches an internals handle in the constructor, and then receives a new family of reactions (formAssociatedCallback, formDisabledCallback, formResetCallback, formStateRestoreCallback) that the standard four lifecycle hooks never expose. The payoff is a control that works with <form>, FormData, :invalid, and submit buttons without any framework glue.
Concept Definition & Spec Grounding
Form association is defined in the WHATWG HTML Standard, section “Custom elements,” under the form-associated custom elements algorithms. The standard introduces the ElementInternals interface and the form-related custom element reactions. Two preconditions must hold for a custom element to become form-associated:
- The element’s class declares
static formAssociated = true. The browser reads this flag when the element is defined viacustomElements.define()and marks the constructed prototype as a form-associated custom element. - The element calls
this.attachInternals()exactly once. This returns anElementInternalsobject — the only legitimate channel through which the element communicates value, validity, and ARIA semantics to the user agent.
Because form association is opt-in at the class level, it composes cleanly with the rest of the Lifecycle Callbacks Deep Dive: the standard constructor/connectedCallback/disconnectedCallback/attributeChangedCallback reactions still fire, and the form reactions are layered on top. The ElementInternals object also doubles as the supported surface for the Accessibility Object Model, which is covered in Accessibility & Focus Management.
class CheckBox extends HTMLElement {
static formAssociated = true; // (1) opt in at definition time
#internals;
constructor() {
super();
this.#internals = this.attachInternals(); // (2) acquire the handle once
this.attachShadow({ mode: 'open' });
}
}
customElements.define('my-checkbox', CheckBox);
If attachInternals() is called on a class that did not declare static formAssociated = true, the returned object still exists but its form-related methods (setFormValue, setValidity, form, labels) throw NotSupportedError. Calling attachInternals() twice on the same element also throws.
Browser Engine Integration Points
The user agent wires a form-associated custom element into the form-owner machinery the moment the element is inserted into a tree that has a form owner, not when it is constructed. Concretely:
- Form-owner resolution happens during DOM insertion. The element acquires a form owner the same way a native control does — the nearest ancestor
<form>, or the form referenced by aform="id"attribute. When the owner changes, the browser invokesformAssociatedCallback(form)on the microtask-driven custom element reaction queue. - Value collection happens during the construct the entry list algorithm, which runs when the form is submitted, when
new FormData(formElement)is constructed, and when the validation steps run. At that point the browser reads the value most recently passed tosetFormValue(). A control with nonameattribute is skipped, exactly like a native control. - Validity styling is recomputed by the style engine whenever
setValidity()mutates the validity flags. The:invalid,:valid,:user-invalid, and:user-validpseudo-classes match against the flag state stored onElementInternals, not against any attribute you set. - Custom state styling is recomputed when
internals.states(aCustomStateSet) is mutated. The style engine re-evaluates:state(name)selectors against the current set.
All of these reactions are enqueued on the same custom element reaction queue as connectedCallback, so they fire in tree order and are observable synchronously within a microtask checkpoint.
Core API Surface
The full form-associated surface is reached through the ElementInternals instance returned by attachInternals(). The table below enumerates the members that matter for form participation.
| Member | Type | Purpose |
|---|---|---|
static formAssociated |
boolean |
Class-level opt-in. Must be true before define(). |
internals.setFormValue(value) |
(string | File | FormData | null) |
Sets the submitted value. null means “no entry.” |
internals.setFormValue(value, state) |
second arg | Separates the submission value from the restoration state. |
internals.form |
HTMLFormElement | null |
The current form owner. |
internals.labels |
NodeList |
<label> elements associated with the host. |
internals.setValidity(flags, message?, anchor?) |
void |
Sets constraint-validation flags; empty {} clears them. |
internals.checkValidity() |
boolean |
Returns validity; fires a cancelable invalid event if failing. |
internals.reportValidity() |
boolean |
Same as above, plus shows the browser’s validation UI. |
internals.validity |
ValidityState |
Read-only flag object (valueMissing, customError, …). |
internals.validationMessage |
string |
The message passed to setValidity(). |
internals.willValidate |
boolean |
Whether the control is a candidate for validation. |
internals.states |
CustomStateSet |
Set of custom state identifiers for :state(). |
The setFormValue() two-argument form is the subtle one. The first argument is what gets submitted; the optional second argument is what is handed back to formStateRestoreCallback during history navigation or autofill. A rich control (for example, a multi-select that submits a comma-joined string but restores from a structured snapshot) uses both.
class TagInput extends HTMLElement {
static formAssociated = true;
static observedAttributes = ['name'];
#internals = this.attachInternals();
#tags = [];
// Submit one FormData entry per tag, but restore from a JSON snapshot.
#commit() {
const data = new FormData();
const name = this.getAttribute('name') ?? '';
for (const tag of this.#tags) data.append(name, tag);
this.#internals.setFormValue(data, JSON.stringify(this.#tags));
}
}
Passing a FormData object to setFormValue() is how a single custom element contributes multiple entries to the submission — each append() becomes its own name=value pair in the submitted body, independent of the host’s own name attribute.
Production Implementation Pattern
The following is a complete, self-contained form-associated control. It is a star rating that submits a numeric value, validates as required, restores after navigation, reacts to a disabled <fieldset>, and exposes a --has-value custom state to CSS.
class StarRating extends HTMLElement {
static formAssociated = true;
static observedAttributes = ['required', 'value'];
#internals;
#shadow;
#value = null;
constructor() {
super();
this.#internals = this.attachInternals();
this.#shadow = this.attachShadow({ mode: 'open', delegatesFocus: true });
this.#shadow.innerHTML = `
<style>
:host { display: inline-flex; gap: 4px; cursor: pointer; }
:host([disabled]) { opacity: .5; pointer-events: none; }
button { all: unset; font-size: 1.4rem; color: #888; }
button[aria-pressed="true"] { color: #ffbf7a; }
:host(:state(--has-value)) { outline: 1px dashed #29c587; }
</style>
${[1, 2, 3, 4, 5].map(n =>
`<button type="button" data-star="${n}" aria-pressed="false">★</button>`).join('')}`;
}
connectedCallback() {
this.#internals.role = 'radiogroup';
this.#shadow.addEventListener('click', (e) => {
const star = e.target.closest('[data-star]');
if (star) this.value = Number(star.dataset.star);
});
this.#refreshValidity();
}
get value() { return this.#value; }
set value(v) {
this.#value = v == null ? null : Number(v);
// The submitted entry uses the host's name attribute.
this.#internals.setFormValue(this.#value == null ? null : String(this.#value));
this.#paint();
this.#refreshValidity();
if (this.#value == null) this.#internals.states.delete('--has-value');
else this.#internals.states.add('--has-value');
}
#refreshValidity() {
if (this.hasAttribute('required') && this.#value == null) {
this.#internals.setValidity(
{ valueMissing: true },
'Please choose a rating.',
this.#shadow.querySelector('[data-star="1"]')
);
} else {
this.#internals.setValidity({}); // valid
}
}
#paint() {
for (const b of this.#shadow.querySelectorAll('[data-star]')) {
b.setAttribute('aria-pressed', Number(b.dataset.star) <= (this.#value ?? 0));
}
}
// --- Form lifecycle reactions ---
formResetCallback() { this.value = null; }
formDisabledCallback(disabled) { this.toggleAttribute('disabled', disabled); }
formStateRestoreCallback(state /*, mode */) {
this.value = state ? Number(state) : null;
}
formAssociatedCallback(/* form */) { /* re-bind to a new owner if needed */ }
}
customElements.define('star-rating', StarRating);
The matching markup is ordinary HTML — no framework binding, no value plumbing:
<form id="review">
<star-rating name="stars" required></star-rating>
<button type="submit">Submit</button>
</form>
The Form Lifecycle Reactions in Detail
Form association introduces four reactions beyond the standard four custom element callbacks. Each is invoked on the same reaction queue and must be declared as an ordinary method on the class; the browser calls them automatically once static formAssociated = true is set.
formAssociatedCallback(form)runs whenever the element gains or loses a form owner — when it is inserted into a<form>, when itsform="id"attribute changes, or when the owning form is removed (in which caseformisnull). Use it to wire up or release any form-scoped listeners. Do not assume it fires exactly once; it can fire repeatedly as the element is moved across forms.formDisabledCallback(disabled)fires when the control’s effective disabled state changes, including when an ancestor<fieldset disabled>toggles. The boolean argument is the new state. A control that ignores this reaction will look enabled while its value is excluded from submission, producing a confusing UX. Reflect it to adisabledattribute and gate interaction on it.formResetCallback()fires when the owning form is reset, via the reset button orform.reset(). Restore the control to its default value here — typically the value reflected from the initialvalueattribute, not an empty string, so the reset semantics match native controls.formStateRestoreCallback(state, mode)fires during history navigation (back/forward) and, withmode === 'autocomplete', during browser autofill. Thestateargument is whatever was passed as the second argument to the most recentsetFormValue()call (or the first argument if no second was supplied). Rehydrate the control fromstateand immediately re-commit viasetFormValue()so the restored value is also submittable.
class ToggleSwitch extends HTMLElement {
static formAssociated = true;
#internals = this.attachInternals();
#on = false;
#commit() {
this.#internals.setFormValue(this.#on ? 'on' : null, String(this.#on));
}
formResetCallback() { this.#on = this.hasAttribute('checked'); this.#commit(); }
formDisabledCallback(disabled) { this.toggleAttribute('disabled', disabled); }
formStateRestoreCallback(state, mode) {
this.#on = state === 'true';
this.#commit();
}
}
A subtle ordering guarantee: formResetCallback runs after the form’s reset event default action begins but the control is responsible for its own visual reset — the browser does not clear your shadow DOM for you. Likewise, formStateRestoreCallback may run before connectedCallback completes its own initialization in some navigation paths, so make restoration idempotent and tolerant of partially-initialized state.
Production Validation & Contract Testing
Because validity and submission live entirely inside ElementInternals, tests should assert against the form’s observable contract rather than internal fields. The reliable assertions are: the FormData produced by new FormData(formEl) contains the expected entries; formEl.checkValidity() returns the expected boolean; and el.matches(':invalid') tracks the validity flags. Run these in a real browser (Playwright, Web Test Runner) — jsdom does not implement the form-associated upgrade algorithm or the validity pseudo-classes.
// Web Test Runner / Playwright
test('required control blocks submission until filled', async ({ page }) => {
await page.setContent(`
<form id="f"><star-rating name="stars" required></star-rating></form>
<script type="module" src="/star-rating.js"></script>`);
await page.waitForFunction(() => customElements.get('star-rating'));
const validEmpty = await page.evaluate(() => f.checkValidity());
expect(validEmpty).toBe(false); // contract: required, unset
await page.evaluate(() => {
document.querySelector('star-rating').value = 4;
});
const data = await page.evaluate(() => [...new FormData(f)]);
expect(data).toContainEqual(['stars', '4']); // contract: submits the value
});
Common Failure Modes & Debugging Steps
-
Value silently dropped on submit. The element is form-associated and has a
name, but the submittedFormDatahas no entry for it. Root cause:setFormValue()was never called (or was called withnull). Fix: callsetFormValue()from every code path that changes the value, including the initialconnectedCallback. This MRE is dissected in Participating in Native Form Submission. -
:invalidnever matches. Styling keyed on:invalidnever applies even when the control is empty. Root cause: the browser’s validity pseudo-classes react only to flags set throughElementInternals.setValidity(), never to attributes or internal booleans. Fix: callsetValidity({ valueMissing: true }, message, anchor)and clear it withsetValidity({}). See Exposing Custom Validity States to CSS. -
attachInternals()throws. ANotSupportedErroris raised at construction. Root cause: eitherstatic formAssociated = trueis missing, orattachInternals()is being called twice. Fix: declare the static flag and store the single returned handle in a private field. -
formDisabledCallbacknever fires. Wrapping the control in a disabled<fieldset>has no effect. Root cause: the element is not form-associated (missing static flag), so the disabled-state propagation never reaches it. Fix: confirm the static flag, then implementformDisabledCallback(disabled)and reflect it visually.
Debugging Pitfall: Reading internals.form inside the constructor always returns null. Form-owner resolution happens at insertion time, so query internals.form no earlier than connectedCallback or formAssociatedCallback.
Framework Interop
- React (≤18) sets custom-element data via attributes, so a
valueproperty you expect from React props will not arrive unless you reflect avalueattribute or use a ref. React 19 passes object/boolean props as properties, which interoperates more cleanly. Either way, native submission works because the value lives inElementInternals, independent of React’s synthetic event system — a<form>with a React-rendered<star-rating name="stars">still submits the rating. - Vue 3 binds
v-modelto avalueprop and a matching event. Expose avalueproperty and dispatch aninput/changeevent from the control; mark the tag incompilerOptions.isCustomElement. Native form submission is unaffected by Vue’s reactivity. - Angular requires
CUSTOM_ELEMENTS_SCHEMA. To bridge intongModel/reactive forms, wrap the element with aControlValueAccessor; the underlyingElementInternalsvalue continues to drive plain<form>submission. - SSR:
attachInternals()and form reactions are client-only. Server-rendered markup should emit a sensible default (e.g., anaria-pressedsnapshot) and letconnectedCallbackre-establish theElementInternalsvalue on hydration. Avoid emitting a placeholder<input>that would create a duplicateFormDataentry.
Performance & Memory Implications
ElementInternals adds negligible overhead: it is a thin host object created once per instance and released when the element is garbage-collected, so there is no observer to disconnect. The two costs worth tracking are:
- Validity churn. Each
setValidity()call can invalidate style for the:invalid/:validpseudo-classes. Recompute validity only when the value actually changes (dirty-check, as in the pattern above) rather than on every keystroke. setFormValuewithFormData. Building a freshFormDataper change for a large multi-value control allocates a new object each time. For high-frequency updates, debounce the commit or reuse a value array and only materializeFormDataat commit time.
There is no listener to leak from form association itself, but any listeners you add (as in connectedCallback) must still be torn down — register them with an AbortController signal and abort it in disconnectedCallback, consistent with the teardown patterns in the Lifecycle Callbacks Deep Dive.
Browser Compatibility & Polyfill Strategy
| Capability | Chromium | Firefox | Safari |
|---|---|---|---|
static formAssociated + attachInternals() |
77 (2019) | 98 (2022) | 16.4 (2023) |
setFormValue / setValidity / form reactions |
77 | 98 | 16.4 |
ElementInternals.states (CustomStateSet) |
90 | 126 | 17.4 |
:state(name) pseudo-class |
125 | 126 | 17.4 |
The older custom-state syntax :--name (dashed-ident form) shipped in earlier Chromium before the standardized :state(--name) / :state(name) form; author against :state() and treat the dashed-ident syntax as legacy. Feature-detect with 'attachInternals' in HTMLElement.prototype before relying on form association, and 'states' in internals before using custom states. A full polyfill exists (@webcomponents/element-internals-polyfill) that shims setFormValue/setValidity by injecting hidden <input> elements; load it conditionally so modern engines pay no cost. Where the polyfill is undesirable, degrade gracefully by submitting through a visually hidden native <input> that mirrors the custom element’s value.
Related
- Participating in Native Form Submission — fix a value dropped from
FormDataon submit. - Exposing Custom Validity States to CSS — make
:invalidand:state()react to a custom control. - Lifecycle Callbacks Deep Dive — how form reactions layer onto the core lifecycle.
- Accessibility & Focus Management — using
ElementInternalsfor ARIA roles and focus. - Core Architecture & Lifecycle Management — parent section overview.