Form-associated custom elements
GUIDEBefore `ElementInternals`, a custom input had to smuggle a hidden `<input>` into the DOM to be submitted with a form. That workaround is obsolete. A form-associated custom element is a first-class form control — it submits, it validates, it resets, and it restores after a back-button navigation.
Two things make an element form-associated: a static flag the browser reads at definition time, and an internals object obtained in the constructor.
class EInput extends HTMLElement {
// Read by the browser when the element is defined. Setting it later,
// or on the instance, does nothing.
static formAssociated = true;
#internals: ElementInternals;
constructor() {
super();
// Callable exactly once per element. A second call throws.
this.#internals = this.attachInternals();
}
}
customElements.define('e-input', EInput);
That alone gets you the association: the element now appears in form.elements, it inherits a form property, and the four lifecycle callbacks below start firing. What it does not get you is a value — you have to publish one.
Publishing a value
setFormValue() is what puts the control into FormData and into a submission. Call it whenever the value changes, and call it with the element's name already set on the element itself — the internals object reads the name from the DOM.
set value(next: string) {
this.#value = next;
// Accepts string | File | FormData | null.
// null means "submit nothing" — not "submit an empty string".
this.#internals.setFormValue(next === '' ? null : next);
}
A control that needs to submit several entries under different names passes a FormData instead. This is how a range control submits from and to from one element.
const data = new FormData();
data.append(`${this.name}-from`, this.#from);
data.append(`${this.name}-to`, this.#to);
this.#internals.setFormValue(data);
Validation
setValidity() wires the element into constraint validation, so :invalid, form.checkValidity() and reportValidity() all work. It takes three arguments and the third is the one people forget.
#validate() {
if (this.required && this.#value === '') {
this.#internals.setValidity(
{ valueMissing: true },
'Please fill in this field.',
// Third argument: the element the browser should focus and anchor
// the validation bubble to. Omit it and reportValidity() throws
// when the control is invalid.
this.#field,
);
return;
}
// Empty flags object clears every constraint.
this.#internals.setValidity({});
}
- The flags object is replaced, not merged. Passing
{ tooShort: true }clearsvalueMissing. - A non-empty flags object requires a non-empty message. The pair is validated together.
- The anchor must be a descendant of your element — in light DOM that is any node you rendered, in shadow DOM it must be inside your shadow root.
- Setting
willValidateis not your job. The browser derives it from disabled state and ancestry.
The four lifecycle callbacks
These fire only on form-associated elements, and only when the static flag was set at definition time.
| Callback | When | What you must do |
|---|---|---|
formAssociatedCallback(form) | Element is associated with, or removed from, a form | Rarely anything. form is null on removal. |
formDisabledCallback(disabled) | Own or ancestor <fieldset> disabled state changes | Update your rendering and stop accepting input. |
formResetCallback() | form.reset() or a reset button | Restore the value to its default and re-publish it. |
formStateRestoreCallback(state, mode) | Back/forward navigation, or browser autofill | Adopt the restored state. mode is restore or autocomplete. |
formStateRestoreCallback is the one that separates a control that feels native from one that does not. Without it, a user who submits a form, navigates back, and finds their custom controls blank while the native inputs are still filled in will — correctly — conclude the page is broken.
formResetCallback(): void {
this.value = this.getAttribute('value') ?? '';
}
formStateRestoreCallback(state: string | File | FormData, _mode: string): void {
// Whatever was passed as setFormValue()'s second argument comes back
// here — parse it with the same code that produced it.
this.value = typeof state === 'string' ? state : '';
}
Labels, focus and the accessibility contract
Form association gives you this.#internals.labels, and a <label for> pointing at your element will focus it — but only if the element is focusable and forwards that focus somewhere sensible. Set tabindex deliberately, and delegate to the interactive node you rendered.
// The host is the label target; the real focus goes to the field.
connectedCallback() {
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '-1');
}
focus(options?: FocusOptions) {
this.#field.focus(options);
}
ElementInternals also exposes ARIA reflection — internals.role, internals.ariaRequired and friends. These set defaults in the accessibility tree without writing attributes onto the element, which means a consumer can still override them with real attributes. That is the correct precedence and it is worth using rather than calling setAttribute('role', …) yourself.
Pitfalls
attachInternals()in the constructor, once. A second call throwsNotSupportedError, and calling it beforesuper()is a syntax-level mistake that class fields can cause silently.- Class fields fight custom element constructors. With
useDefineForClassFields: true, TypeScript emits field initialisers that run before the element is upgraded, clobbering attributes already in the DOM. Set it tofalsefor a component library. setFormValue(null)is notsetFormValue(""). The first omits the entry fromFormDataentirely; the second submits an empty string. Match whatever a native input would do for your control type.- Do not publish a value in the constructor. The element may not be connected, and its
namemay not be set yet. Publish inconnectedCallbackand on every change after. - Disabled state has two sources. Your own
disabledattribute and an ancestor<fieldset>.formDisabledCallbackreports the effective state — trust it over reading your own attribute.
A complete minimal control
class ECounter extends HTMLElement {
static formAssociated = true;
static observedAttributes = ['value', 'disabled'];
#internals: ElementInternals;
#value = 0;
#button!: HTMLButtonElement;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
this.innerHTML = '<button type="button" class="ink-btn"></button>';
this.#button = this.querySelector('button')!;
this.#button.addEventListener('click', () => { this.value = this.#value + 1; });
this.value = Number(this.getAttribute('value') ?? 0);
}
get value() { return this.#value; }
set value(next: number) {
this.#value = next;
this.#button.textContent = String(next); // surgical, not innerHTML
this.#internals.setFormValue(String(next));
this.#internals.setValidity(
next < 0 ? { rangeUnderflow: true } : {},
next < 0 ? 'Must not be negative.' : '',
this.#button,
);
this.dispatchEvent(new CustomEvent('e-change', { detail: { value: next } }));
}
formResetCallback() { this.value = Number(this.getAttribute('value') ?? 0); }
formStateRestoreCallback(state: string) { this.value = Number(state) || 0; }
formDisabledCallback(disabled: boolean) { this.#button.disabled = disabled; }
}
EPaper factors this pattern into a BaseFormControl<T> base class: subclasses implement serialize(value) and parse(string), and inherit association, validation plumbing and all four callbacks. Fourteen input components share it, which is the point — the contract above is exactly the kind of thing that decays when it is reimplemented per control.
Support
ElementInternals with form association is available across current Chrome, Edge, Firefox and Safari. Safari was the last to land it and shipped form association in 16.4; if you must support older Safari, feature-detect with "attachInternals" in HTMLElement.prototype and fall back to a visually hidden <input> rather than shipping a control that silently submits nothing.
See the live showcase for a form built entirely from these controls, including FormData output and validation.