Form-Associated Custom Elements
Design systems build their own inputs — rating stars, tag pickers, colour swatches, date-range selectors, rich comboboxes — and for years those components sat outside the browser’s form model. Their values did not appear in FormData, required meant nothing, form.checkValidity() ignored them, reportValidity() could not focus them, and form.reset() left them untouched. Teams patched the gap with hidden inputs that mirrored the value, which worked until the mirror drifted. Form-associated custom elements close the gap properly: with static formAssociated = true and the ElementInternals API, a custom element becomes a first-class form control that submits a value, participates in constraint validation, reports errors through the browser’s own UI, responds to reset, disabled fieldsets and autofill restoration, and can be labelled by an ordinary <label>. This topic shows how to build one that behaves exactly like a native input inside the site’s canonical <form novalidate> plus reportValidity() flow from the Constraint Validation API.
The problem this solves is a whole category of design-system bugs: custom inputs that look like form controls but are invisible to the form. Once a component is form-associated, every generic piece of form code — the submit handler that calls reportValidity(), the error summary that loops over form.elements, the server-error mapper that uses namedItem(), the dirty-tracking helper — works with it unchanged, because to the browser it simply is a form control. That is the real payoff: not the component itself, but the fact that nothing else has to know it is custom.
Prerequisites for Form-Associated Elements
| Requirement | Minimum version | Why it is needed |
|---|---|---|
ElementInternals + formAssociated |
Chrome 77, Firefox 93, Safari 16.4 | The API itself |
| Custom elements v1 | All evergreen browsers | Defining the component |
| Shadow DOM (optional) | All evergreen browsers | Encapsulated markup; needs delegation for focus |
:state() custom states |
Chrome 125, Firefox 126, Safari 17.4 | Styling component-specific states |
| TypeScript DOM lib | 5.0+ | ElementInternals typings |
| Screen reader testing | NVDA, VoiceOver | Verifying announced names, roles and errors |
ElementInternals API Reference
| API | Type | Effect | Notes |
|---|---|---|---|
static formAssociated = true |
class field | Opts the element into forms | Required before attachInternals() is useful |
this.attachInternals() |
() => ElementInternals |
Returns the internals object | Call once, in the constructor |
internals.setFormValue(value, state?) |
(string | File | FormData | null, …) => void |
Sets the submitted value | state restores UI on back/forward |
internals.setValidity(flags, message?, anchor?) |
(ValidityStateFlags, string, HTMLElement) => void |
Sets validity like native constraints | anchor is where the browser points its bubble and focus |
internals.validity / validationMessage |
ValidityState / string |
Current validity | Mirrors native inputs |
internals.checkValidity() / reportValidity() |
() => boolean |
Element-level checks | Form-level calls include the element automatically |
internals.form / labels / willValidate |
properties | Owner form, associated labels, whether validated | willValidate is false when disabled |
formResetCallback() |
lifecycle | Called on form.reset() |
Restore the default value |
formDisabledCallback(disabled) |
lifecycle | Called for disabled fieldsets | Update interactivity and ARIA |
formStateRestoreCallback(state, mode) |
lifecycle | Back/forward cache and autofill | Rebuild UI from saved state |
internals.role / ariaLabel … |
ARIA reflection | Default semantics | Avoid sprouting attributes on the host |
Step-by-Step Implementation
1. Declare the element as form-associated
export class RatingInput extends HTMLElement {
static formAssociated = true;
static observedAttributes = ["required", "max", "value"];
#internals: ElementInternals;
#value = 0;
#buttons: HTMLButtonElement[] = [];
constructor() {
super();
this.#internals = this.attachInternals();
this.#internals.role = "radiogroup"; // default semantics without host attributes
}
get form() { return this.#internals.form; }
get name() { return this.getAttribute("name"); }
get validity() { return this.#internals.validity; }
get validationMessage() { return this.#internals.validationMessage; }
get willValidate() { return this.#internals.willValidate; }
checkValidity() { return this.#internals.checkValidity(); }
reportValidity() { return this.#internals.reportValidity(); }
}
Exposing validity, checkValidity() and friends on the element itself makes it quack like a native input, so generic form code that loops over form.elements can treat it identically.
2. Render, set the value and keep validity in sync
// inside RatingInput
connectedCallback(): void {
const max = Number(this.getAttribute("max") ?? 5);
this.replaceChildren(
...Array.from({ length: max }, (_, i) => {
const b = document.createElement("button");
b.type = "button";
b.setAttribute("role", "radio");
b.setAttribute("aria-label", `${i + 1} of ${max} stars`);
b.addEventListener("click", () => this.#select(i + 1));
return b;
}),
);
this.#buttons = [...this.querySelectorAll("button")];
this.#select(Number(this.getAttribute("value") ?? 0), false);
}
#select(value: number, userAction = true): void {
this.#value = value;
this.#buttons.forEach((b, i) => {
b.setAttribute("aria-checked", String(i + 1 === value));
b.tabIndex = i + 1 === (value || 1) ? 0 : -1; // roving tabindex
});
this.#internals.setFormValue(value ? String(value) : null);
this.#updateValidity();
if (userAction) this.dispatchEvent(new Event("input", { bubbles: true }));
}
#updateValidity(): void {
const anchor = this.#buttons[0];
if (this.hasAttribute("required") && this.#value === 0) {
this.#internals.setValidity({ valueMissing: true }, "Choose a rating from 1 to 5 stars.", anchor);
} else {
this.#internals.setValidity({}); // valid
}
}
The third argument to setValidity — the anchor — is what makes reportValidity() work: the browser focuses that element and points its message at it. Without an anchor inside the component, the browser has nothing to focus. The full recipe, including custom messages via customError, is ElementInternals setValidity for custom inputs.
3. Handle reset, disabled and restore
// inside RatingInput
formResetCallback(): void {
this.#select(Number(this.getAttribute("value") ?? 0), false);
}
formDisabledCallback(disabled: boolean): void {
this.#buttons.forEach((b) => (b.disabled = disabled));
}
formStateRestoreCallback(state: string | null): void {
this.#select(Number(state ?? 0), false);
}
These callbacks are what make the element indistinguishable from native controls in the edge cases teams usually miss: a Reset button, a <fieldset disabled>, and the browser restoring form state when the user navigates back. Custom element form reset and restore covers them in depth.
4. Use it like any other control
<form id="review" novalidate>
<label id="rating-label">Your rating</label>
<rating-input name="rating" required max="5" aria-labelledby="rating-label"></rating-input>
<label for="comment">Comment</label>
<textarea id="comment" name="comment" minlength="10"></textarea>
<button type="submit">Post review</button>
</form>
<script type="module">
customElements.define("rating-input", RatingInput);
const form = document.querySelector("#review");
form.addEventListener("submit", (e) => {
if (!form.reportValidity()) e.preventDefault(); // includes <rating-input> automatically
});
</script>
form.reportValidity() now walks the rating component along with the textarea, in document order, focusing the first invalid one — including the star rating if no rating was chosen.
State Management and Edge Cases
A form-associated element owns three pieces of state that native inputs manage for you: the value submitted with the form, the state used to restore the UI, and the validity. Keep them updated together — every change of value should call setFormValue and recompute validity in the same function, as #select does — or the form will submit one thing while showing another.
- Validity before connection.
setValiditywith an anchor requires the anchor to be in the shadow or light tree of the element. Compute validity inconnectedCallbackafter rendering, not in the constructor. - Attribute changes. Toggling
requiredfrom outside must recompute validity; handle it inattributeChangedCallback. - Disabled. When disabled (directly or through a fieldset),
willValidateis false and the form skips the element — do not also set validity flags you then have to clear. - Validity timing versus error display.
setValidityshould run on every change so the form always knows the truth, but the component should only show its error after interaction or a submit attempt — the same touched-or-submitted rule native inputs follow with:user-invalid. - Complex values.
setFormValueaccepts aFormData, which lets one component submit several named entries (a date range’sstartandend).
Accessibility Compliance for Custom Controls
A form-associated element gets form behaviour, not accessibility, for free — the component still has to expose a correct name, role and state (WCAG 4.1.2). Use ElementInternals ARIA reflection (internals.role, internals.ariaRequired, internals.ariaInvalid) for the host’s default semantics so authors can still override them with attributes. Labels work: a <label for> pointing at the custom element’s id labels it, and internals.labels returns them. Errors need the same treatment as native inputs: a visible message referenced by aria-describedby, and ariaInvalid set when the element’s error is shown. Keyboard support is entirely yours — the rating above needs arrow keys to move between stars, as the ARIA radio group pattern requires.
// inside RatingInput: reflect validity to assistive technology when the error is shown
#showError(visible: boolean): void {
this.#internals.ariaInvalid = visible && !this.#internals.validity.valid ? "true" : "false";
}
Common Gotchas and Debugging
Forgetting the anchor. setValidity({ valueMissing: true }, msg) without an anchor makes the element invalid, but reportValidity() has nowhere to put focus; some browsers then focus nothing and show no message.
Hidden inputs as a shortcut. A hidden input inside the component never participates in validation — hidden inputs are barred from constraint validation. Use setFormValue and setValidity instead.
// Before: mirror into a hidden input (never validated)
this.querySelector("input[type=hidden]")!.value = String(value);
// After: the element is the control
this.#internals.setFormValue(String(value));
this.#internals.setValidity(value ? {} : { valueMissing: true }, "Choose a rating.", this.#buttons[0]);
Shadow DOM and focus. With a shadow root, pass delegatesFocus: true to attachShadow so focusing the host moves focus inside, and make sure the anchor is a focusable element in the shadow tree.
Safari versions before 16.4. Older Safari lacks ElementInternals. Feature-detect with "attachInternals" in HTMLElement.prototype and fall back to rendering a native input, as described below.
Submitting Several Values From One Component
Some components represent more than one value: a date-range picker has a start and an end, a price filter has a minimum and a maximum, a tag input has many tags. setFormValue accepts a FormData object, and every entry in it is submitted as if it came from its own input. That keeps the server’s view simple — it receives start and end, or several tags entries — while the component remains one control for validation purposes, with one validity state and one error message covering the whole range.
// inside a DateRangeInput
#commit(start: string, end: string): void {
const data = new FormData();
const base = this.getAttribute("name") ?? "range";
if (start) data.append(`${base}.start`, start);
if (end) data.append(`${base}.end`, end);
this.#internals.setFormValue(data, `${start}|${end}`); // state string for restoration
if (this.hasAttribute("required") && (!start || !end)) {
this.#internals.setValidity({ valueMissing: true }, "Choose a start and an end date.", this.#startInput);
} else if (start && end && end < start) {
this.#internals.setValidity({ rangeUnderflow: true }, "The end date must be on or after the start date.", this.#endInput);
} else {
this.#internals.setValidity({});
}
}
Note how the anchor changes with the error: a missing value anchors on the start input, an inverted range on the end input, so reportValidity() puts focus exactly where the user needs to act. The cross-field logic itself is the same as in validating date range start before end; the component simply owns it.
Testing Form-Associated Elements
Unit tests in jsdom are of limited use here — jsdom’s ElementInternals support is partial — so test form-associated elements in a real browser. Playwright can exercise every integration point in a few lines: that the value appears in FormData, that form.checkValidity() fails while the component is empty and required, that reportValidity() focuses the anchor, that form.reset() restores the default, and that a disabled fieldset excludes it.
import { test, expect } from "@playwright/test";
test("rating-input behaves like a native control", async ({ page }) => {
await page.goto("/review");
const valid = () => page.evaluate(() => (document.querySelector("#review") as HTMLFormElement).checkValidity());
expect(await valid()).toBe(false); // required and empty
await page.getByRole("button", { name: "Post review" }).click();
await expect(page.getByRole("radio", { name: "1 of 5 stars" })).toBeFocused(); // the anchor
await page.getByRole("radio", { name: "4 of 5 stars" }).click();
expect(await valid()).toBe(true);
const submitted = await page.evaluate(() => Object.fromEntries(new FormData(document.querySelector("#review") as HTMLFormElement)));
expect(submitted.rating).toBe("4");
await page.evaluate(() => (document.querySelector("#review") as HTMLFormElement).reset());
expect(await valid()).toBe(false); // formResetCallback ran
});
Run the same test in Chromium, Firefox and WebKit projects; engine differences in anchoring and focus behaviour show up here before they show up in production. Add an axe-core scan of the component in its error state, as in integrating jest-axe with form components, to catch missing names or states.
Using Form-Associated Elements With Framework Form Libraries
Because a form-associated element dispatches input and change events and exposes value, validity and checkValidity(), it plugs into framework form libraries with little glue. In React, register it with a ref and read value in the library’s onChange wrapper; React 19 also passes properties to custom elements directly. In Vue, v-model works if the element emits input with value updated. In Angular, a small ControlValueAccessor directive bridges the element to Reactive Forms, and the element’s own validity can be surfaced through a validator that reads el.validity. In each case, keep validation rules in one place — ideally the element’s setValidity for intrinsic constraints like “required” and “end after start”, and the form’s schema for business rules — so the component is reusable across frameworks without re-implementing its constraints. The framework-side wiring is covered in framework integration patterns.
Custom Messages and Localisation
The message passed to setValidity is what validationMessage returns and what the browser shows in its bubble, so it deserves the same care as any other error text: a full sentence saying what to do, in the page’s language. Because the component cannot know every product’s wording, accept messages from the outside — through attributes such as data-msg-required, a messages property, or a lookup function injected by the host application — and fall back to a sensible default. When the host application renders its own inline errors (reading validationMessage into a described-by container), the component’s message appears there too, so one string serves both the native bubble and the design-system error style. The localisation approach used for native messages in localizing custom validation messages applies directly: route every message through the same catalogue, and the custom element speaks the same language as every native input on the page.
// inside RatingInput
#message(key: "required"): string {
return this.getAttribute(`data-msg-${key}`) ?? "Choose a rating from 1 to 5 stars.";
}
Styling Validity for Custom Elements
Because a form-associated element participates in constraint validation, the native pseudo-classes apply to it: rating-input:invalid, rating-input:user-invalid and rating-input:disabled all work, the latter even when disabled through a fieldset. Component-specific states — “hovering a star”, “read-only display” — are better expressed with custom states via internals.states.add("…") and styled with :state(…), which avoids leaking implementation classes onto the host. The general guidance on styling after interaction rather than on load, in styling invalid inputs with :user-invalid, applies unchanged.
rating-input:user-invalid { outline: 2px solid var(--error-border); outline-offset: 4px; border-radius: 6px; }
rating-input:disabled { opacity: 0.5; }
rating-input:state(readonly) button { cursor: default; }
Progressive Fallback for Older Browsers
Where ElementInternals is missing, a form-associated component can still degrade gracefully: render a native control that carries the value and the constraint — a <select required> for a rating, an <input type="date"> for a date picker — and upgrade it to the rich UI only when the API is available. The native control then does the validation and submission, and the form’s code does not change. This is the same progressive-enhancement principle behind progressive enhancement without JavaScript: the baseline is a real form control, and the component is an enhancement layered on top.
if (!("attachInternals" in HTMLElement.prototype)) {
// Keep the server-rendered <select name="rating" required> and skip defining the element.
} else {
customElements.define("rating-input", RatingInput);
}
Browser Compatibility Matrix
| Feature | Chromium | Firefox | Safari | Fallback |
|---|---|---|---|---|
ElementInternals form APIs |
77+ | 93+ | 16.4+ | Server-rendered native control |
formStateRestoreCallback |
77+ | 93+ | 16.4+ | Rebuild from the value attribute |
internals.states / :state() |
125+ | 126+ | 17.4+ | Data attributes on the host |
delegatesFocus |
53+ | 94+ | 15+ | Explicit focus() override |
Frequently Asked Questions
What is a form-associated custom element?
A custom element whose class sets static formAssociated = true and uses ElementInternals. It then behaves like a native form control: its value is submitted, it participates in constraint validation, and it responds to reset and disabled fieldsets.
How do I make reportValidity focus my custom element?
Call internals.setValidity with a third argument, the anchor: a focusable element inside your component. The browser focuses the anchor and points its validation message at it.
Can I just use a hidden input inside my component?
Hidden inputs are barred from constraint validation, so required and custom validity on them are ignored, and reportValidity cannot focus them. Use setFormValue and setValidity on ElementInternals instead.
Do :invalid and :user-invalid work on custom elements?
Yes, for form-associated elements. Their validity comes from ElementInternals, so the pseudo-classes match exactly as they do for native inputs.
Related Guides
- ElementInternals setValidity for Custom Inputs — validity flags, messages and anchors in depth.
- Validating Inputs Inside Shadow DOM — encapsulated native inputs.
- Custom Element Form Reset and Restore — lifecycle callbacks.
- Reading ValidityState Flags for Granular Errors — the flags setValidity accepts.
← Back to Mastering HTML5 Native Form Validation