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.

Form-association lifecycle and ElementInternals state flow A diagram showing how static formAssociated, attachInternals, and ElementInternals route values, validity, and custom states into the owning form and CSS. Custom Element static formAssociated = true attachInternals() ElementInternals setFormValue(v) setValidity(flags, msg) states.add(name) form / labels attachInternals() handle Form submission FormData entries by name Constraint validation :invalid / :user-invalid Custom states :state(name) in CSS Form reactions reset / disabled / restore

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:

  1. The element’s class declares static formAssociated = true. The browser reads this flag when the element is defined via customElements.define() and marks the constructed prototype as a form-associated custom element.
  2. The element calls this.attachInternals() exactly once. This returns an ElementInternals object — 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:

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.

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

  1. Value silently dropped on submit. The element is form-associated and has a name, but the submitted FormData has no entry for it. Root cause: setFormValue() was never called (or was called with null). Fix: call setFormValue() from every code path that changes the value, including the initial connectedCallback. This MRE is dissected in Participating in Native Form Submission.

  2. :invalid never matches. Styling keyed on :invalid never applies even when the control is empty. Root cause: the browser’s validity pseudo-classes react only to flags set through ElementInternals.setValidity(), never to attributes or internal booleans. Fix: call setValidity({ valueMissing: true }, message, anchor) and clear it with setValidity({}). See Exposing Custom Validity States to CSS.

  3. attachInternals() throws. A NotSupportedError is raised at construction. Root cause: either static formAssociated = true is missing, or attachInternals() is being called twice. Fix: declare the static flag and store the single returned handle in a private field.

  4. formDisabledCallback never 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 implement formDisabledCallback(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

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:

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.