Skip to content

Form-Associated Components

Form-Associated Custom Elements is a platform API that lets a custom element participate in native HTML forms exactly like a built-in <input> — submitting a value, supporting :invalid, and working with FormData. This lesson covers integrating it with Lit.

Why Native Form Participation Matters

A custom element wrapping an <input> inside its Shadow DOM does not automatically participate in an ancestor <form>'s submission — its internal <input> is inside a separate Shadow DOM tree, invisible to FormData and native form submission by default. The Form-Associated Custom Elements API (via ElementInternals) solves this by letting your custom element itself register as a genuine form control.

This is an advanced, platform-level feature: setting static formAssociated = true on your class, then using this.attachInternals() to get an ElementInternals object with methods like setFormValue() and setValidity() that hook your component directly into the browser's own form machinery.

class StarRatingInput extends LitElement {
  static formAssociated = true;

  #internals = this.attachInternals();

  @property({ type: Number }) value = 0;

  #setValue(newValue) {
    this.value = newValue;
    this.#internals.setFormValue(String(newValue));
  }

  render() {
    return html`${[1,2,3,4,5].map((n) =>
      html`<button type="button" @click=${() => this.#setValue(n)}>${n <= this.value ? '\u2605' : '\u2606'}</button>`
    )}`;
  }
}
customElements.define('star-rating-input', StarRatingInput);

Once setFormValue() is called, <star-rating-input> behaves like a real form control — its value is included in new FormData(form) automatically.

The Form-Associated API Surface

static formAssociated = true;         // opts into the form-associated behavior
this.attachInternals();               // returns an ElementInternals instance

internals.setFormValue(value);        // sets the value submitted with the form
internals.setValidity({ valueMissing: true }, 'message'); // custom validity
internals.form;                       // reference to the associated <form>, if any
internals.labels;                     // NodeList of associated <label> elements
  • static formAssociated = true must be set for attachInternals() to return a form-participating ElementInternals object.
  • setFormValue() accepts a string, File, FormData, or null (to exclude the control from submission).
  • setValidity() lets your custom element participate in native constraint validation, including CSS :invalid matching.
  • Lifecycle callbacks like formAssociatedCallback, formResetCallback, and formDisabledCallback are available for deeper integration.

Form-Associated Lifecycle Callbacks

Additional callbacks available once formAssociated is true.

Callback Fires When
formAssociatedCallback(form) The element is associated with a form
formDisabledCallback(disabled) The containing fieldset's disabled state changes
formResetCallback() The form is reset, e.g. via form.reset()
formStateRestoreCallback(state, mode) Browser autofill/restore restores the value

Participating in Constraint Validation

Beyond just submitting a value, ElementInternals lets your component report its own validity state, so native form validation (form.reportValidity(), the :invalid CSS pseudo-class, and required semantics) works with your custom element exactly as it would with a built-in <input>.

#validate() {
  if (this.value === 0) {
    this.#internals.setValidity({ valueMissing: true }, 'Please select a rating');
  } else {
    this.#internals.setValidity({}); // clears the error — the control is valid
  }
}

Clearing validity with an empty object is required once the control becomes valid again — otherwise the invalid state persists.

When This Complexity Is Actually Worth It

Form-Associated Custom Elements is genuinely advanced — reach for it specifically when you're building a reusable, shareable custom input control (a design system's date picker, rating widget, or rich select) that needs to work seamlessly inside any ordinary <form>, including with FormData, native validation, and form resets. For simpler internal application forms, the plain property/event pattern from the previous lesson is usually sufficient.

Common Mistakes

  • Forgetting static formAssociated = true, in which case attachInternals() returns an object without the form-participation methods available.
  • Never calling setFormValue() after the value changes, so the control silently contributes nothing to FormData.
  • Implementing custom validity logic but forgetting to clear it with setValidity({}) once the control becomes valid again.
  • Reaching for this advanced API for simple internal forms where the plain property/event pattern would be far simpler and sufficient.

Key Takeaways

  • Form-Associated Custom Elements let a custom element participate natively in <form> submission and validation.
  • static formAssociated = true plus this.attachInternals() is the entry point to the ElementInternals API.
  • setFormValue() and setValidity() are the two core methods most components need.
  • Reserve this complexity for genuinely reusable, shareable form control components.

Pro Tip

Test a form-associated component the same way QA would test a native <input>: put it inside a real <form>, submit it, reset it, and check new FormData(form) in the console — that end-to-end check catches integration gaps that unit-testing the component in isolation would miss entirely.