Skip to content

Form Associated Custom Elements

This lesson explains form-associated custom elements (FACE), the browser APIs that let custom elements participate in forms like native inputs, and the patterns for building real form controls.

What Are Form-Associated Custom Elements?

By default, custom elements are not form controls. A tag like <app-rating> can look like an input, but the browser will not include its value in form submission unless you opt into form association.

Form-associated custom elements (often abbreviated FACE) use the formAssociated flag and ElementInternals to participate in the same form lifecycle as native fields: value submission, reset, disabled state, and validation.

API Purpose
static formAssociated = true Marks the class as a form control
attachInternals() Connects the element to form internals
internals.setFormValue() Sets the submitted form value
internals.form Returns the associated <form>
internals.labels Returns associated <label> elements
formResetCallback() Runs when the parent form resets

Basic Form-Associated Element

class AppTextInput extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.attachShadow({ mode: "open" });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML =
      `<input part="input" type="text" />`;

    this.input = this.shadowRoot.querySelector("input");
    this.input.addEventListener("input", () => {
      this.internals.setFormValue(this.input.value);
    });
  }
}

customElements.define("app-text-input", AppTextInput);
<form action="/api/profile" method="post">
  <label>
    Display name
    <app-text-input name="displayName"></app-text-input>
  </label>
  <button type="submit">Save</button>
</form>

With name="displayName", the custom element submits its value through the normal form payload, just like a native input.

How Form Association Works

Step What Happens
1. Opt in Set static formAssociated = true
2. Attach internals Call this.attachInternals() in the constructor
3. Set value Call internals.setFormValue() when the control changes
4. Add name Use a name attribute in markup
5. Submit form Browser includes the control in form data

Without these steps, a custom element is only visual. Form association turns it into a real control with browser-native form behavior.

Setting the Form Value

// Simple string value
this.internals.setFormValue("alex@example.com");

// File-like or complex values
this.internals.setFormValue(file);

// Value plus state for restoration
this.internals.setFormValue(value, state);

Call setFormValue() whenever the user changes the control. The first argument is what gets submitted. The optional second argument stores restoration state for features like browser back-forward cache.

Associating with a Form

<form id="signup-form">
  <!-- fields here -->
</form>

<app-text-input
  name="referral"
  form="signup-form"
></app-text-input>

Like native inputs, a custom control can live outside the <form> element and still participate through the form attribute. Access the linked form with this.internals.form.

Labels and Name Association

<label for="email-field">Email</label>
<app-email-input
  id="email-field"
  name="email"
></app-email-input>
const labels = this.internals.labels;
console.log(labels.length); // linked label elements

Form-associated elements support label association through for/id or wrapping labels, and expose linked labels through internals.labels.

Form Lifecycle Callbacks

Form-associated elements can implement additional callbacks that mirror native control behavior.

class AppRating extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this._value = 0;
  }

  formResetCallback() {
    this._value = 0;
    this.render();
    this.internals.setFormValue("");
  }

  formDisabledCallback(disabled) {
    this.toggleAttribute("data-disabled", disabled);
    this.shadowRoot.querySelector("button").disabled = disabled;
  }

  formStateRestoreCallback(state, mode) {
    if (mode === "restore") {
      this._value = state;
      this.render();
      this.internals.setFormValue(String(state));
    }
  }
}
Callback When It Runs
formResetCallback() Parent form calls reset()
formDisabledCallback(disabled) Control becomes enabled or disabled
formStateRestoreCallback(state, mode) Browser restores saved form state

Disabled State

<fieldset disabled>
  <app-text-input name="nickname"></app-text-input>
</fieldset>

When a form-associated element is disabled through the disabled attribute or a disabled fieldset, the browser calls formDisabledCallback(true). Implement this callback to disable internal inputs and block interaction.

Form-Associated vs Regular Custom Elements

Feature Regular Custom Element Form-Associated Element
Form submission Not included automatically Included via setFormValue()
Validation Manual only Via ElementInternals
Reset behavior Manual only formResetCallback()
Disabled behavior Manual only formDisabledCallback()
Typical use UI widgets, layout shells Inputs, selects, toggles, pickers

Checkbox-Style Control Example

class AppToggle extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
    this.checked = false;
  }

  connectedCallback() {
    this.addEventListener("click", () => {
      this.checked = !this.checked;
      this.internals.setFormValue(this.checked ? "on" : null);
      this.toggleAttribute("checked", this.checked);
    });
  }

  formResetCallback() {
    this.checked = false;
    this.removeAttribute("checked");
    this.internals.setFormValue(null);
  }
}
<app-toggle name="newsletter"></app-toggle>

Checkbox-like controls often submit a value when checked and null when unchecked, matching native checkbox behavior.

Composite Form Controls

One form-associated element can wrap multiple internal inputs while still submitting a single logical value.

class DateRangePicker extends HTMLElement {
  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
  }

  updateValue() {
    const start = this.startInput.value;
    const end = this.endInput.value;
    const payload = JSON.stringify({ start, end });

    this.internals.setFormValue(payload);
  }
}

Composite controls are useful for date ranges, address blocks, and rating groups that should appear as one field to the form.

Form Submission Behavior

form.addEventListener("submit", (event) => {
  event.preventDefault();

  const data = new FormData(form);
  console.log(data.get("displayName"));
});

Once values are set with setFormValue(), FormData, form.submit(), and normal POST requests include the custom control automatically.

When to Use Form-Associated Elements

  • You build custom inputs for a shared design system.
  • The control must submit data with a normal HTML form.
  • You need reset and disabled behavior like native fields.
  • You want progressive enhancement without a JavaScript form library.
  • You wrap complex UI into one named form field.
  • You integrate with server-rendered forms and FormData.

Form Association Best Practices

  • Set static formAssociated = true on the class.
  • Call attachInternals() once in the constructor.
  • Update setFormValue() on every user-driven change.
  • Support name, labels, and disabled state.
  • Implement formResetCallback() for resettable controls.
  • Keep the submitted value shape stable and documented.
  • Add validation through ElementInternals for real forms.

Common Mistakes

  • Forgetting formAssociated = true on the class.
  • Never calling setFormValue() after user input.
  • Assuming a name attribute alone makes submission work.
  • Calling attachInternals() outside the constructor.
  • Not handling formResetCallback() for resettable fields.
  • Ignoring disabled state in composite controls.
  • Submitting unstable JSON shapes without documenting them.

Key Takeaways

  • Custom elements are not form controls unless you opt in.
  • formAssociated and attachInternals() enable form behavior.
  • setFormValue() connects user input to form submission.
  • Form callbacks handle reset, disabled state, and restoration.
  • Form-associated elements can replace many native input use cases.
  • Validation and semantics build on the same internals API.

Pro Tip

If your custom element collects user data that should appear in FormData, make it form-associated from the start. Retrofitting form behavior later is much harder than designing the value API early.