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.

Hidden-input mirror versus a form-associated element Two columns comparing the old approach of mirroring a custom component's value into a hidden input with making the component itself form-associated through ElementInternals. Hidden input mirror • component + <input type="hidden"> ✗ hidden inputs are barred from validation ✗ reset and disabled fieldsets ignored ✗ focus from reportValidity goes nowhere formAssociated + ElementInternals ✓ value in FormData via setFormValue ✓ setValidity joins checkValidity / reportValidity ✓ formResetCallback, formDisabledCallback ✓ labels, :invalid and :user-invalid work
A form-associated element is a real control: the form sees its value, validity, reset and disabled state directly.

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.

reportValidity with a custom element in the form The form's reportValidity visits controls in order, asks the custom element's internals for validity, and the browser focuses the anchor element inside the component and shows its message. Form rating-input internals Anchor button willValidate? validity? valueMissing, "Choose a rating…" focus() browser shows the message at the anchor reportValidity() returns false
The anchor passed to setValidity is where focus and the browser's message go; without it, reportValidity cannot point anywhere inside the component.

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. setValidity with an anchor requires the anchor to be in the shadow or light tree of the element. Compute validity in connectedCallback after rendering, not in the constructor.
  • Attribute changes. Toggling required from outside must recompute validity; handle it in attributeChangedCallback.
  • Disabled. When disabled (directly or through a fieldset), willValidate is false and the form skips the element — do not also set validity flags you then have to clear.
  • Validity timing versus error display. setValidity should 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. setFormValue accepts a FormData, which lets one component submit several named entries (a date range’s start and end).
Custom element validity lifecycle The element is constructed without validity, becomes connected and computes validity, updates on each value or attribute change, and is skipped when disabled. constructed connected, valid connected, invalid disabled (skipped) connected, has value value chosen cleared or required added connected, required, empty fieldset disabled re-enabled
Validity is recomputed on every change of value or constraint, and a disabled element is simply excluded from validation.

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

Form-associated custom element support Support across Chromium, Firefox and Safari for the ElementInternals features used by form-associated custom elements. Chromium Firefox Safari formAssociated + setFormValue ✓ 77+ ✓ 93+ ✓ 16.4+ setValidity with anchor ✓ 77+ ✓ 93+ ✓ 16.4+ ARIA reflection on internals ✓ 91+ ✓ 119+ ✓ 16.4+ :state() custom states ✓ 125+ ✓ 126+ ✓ 17.4+
Every current engine supports the core API; custom states are the most recent addition.
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.

← Back to Mastering HTML5 Native Form Validation

Explore This Section