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.
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.
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.
Validation and ElementInternals
Form association is the foundation. Validation builds on the same
ElementInternals object through
setValidity(), checkValidity(), and
reportValidity(). See the
Form Validation
and
ElementInternals
lessons for the next steps.
this.internals.setValidity(
{ valueMissing: !value },
"This field is required."
);
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.