Do not fall back to a hidden <input> to surface a custom control's value to a form. Use Form-Associated Custom Elements (FACE): with formAssociated and ElementInternals, the element submits its own value, participates in validation, and reflects form state natively.
class RatingInput extends HTMLElement {
// MANDATORY: opt in. Without this, attachInternals() form APIs are unavailable.
static formAssociated = true;
#internals;
#value = '';
constructor() {
super();
this.#internals = this.attachInternals();
this.attachShadow({ mode: 'open', delegatesFocus: true });
}
connectedCallback() {
// Validate initial state once attributes (such as `required`) are present.
this.#validate();
}
get value() { return this.#value; }
set value(v) {
this.#value = v;
// Push the value into the owning <form> for submission.
this.#internals.setFormValue(v);
this.#validate();
}
#validate() {
if (this.hasAttribute('required') && !this.#value) {
// Mark invalid and supply the native validation message + anchor.
this.#internals.setValidity(
{ valueMissing: true },
'Please choose a rating.',
this.shadowRoot.querySelector('[tabindex]'),
);
} else {
this.#internals.setValidity({}); // clear
}
}
// Optional FACE lifecycle hooks:
formResetCallback() { this.value = ''; }
formDisabledCallback(disabled) {
// Disable internal controls so they leave the tab order and ignore clicks.
// Do NOT toggle `disabled` on `this`, or a `<fieldset disabled>` toggle will
// leave the host permanently stuck with its own `disabled` attribute.
for (const el of this.shadowRoot.querySelectorAll('button')) {
el.disabled = disabled;
}
}
formStateRestoreCallback(state) { this.value = state; }
}
customElements.define('rating-input', RatingInput);
Key points:
static formAssociated = trueis MANDATORY; it unlocks the form-relatedElementInternalsmethods and theformXxxCallbacklifecycle.setFormValue(value)is what submits, with no hidden input required. Pass aFormDatafor multi-value controls.setValidity(flags, message, anchor)integrates with native constraint validation: the form won't submit while invalid,:invalid/:validapply, and the anchor element receives focus on report. CallsetValidity({})to clear. The interaction-aware:user-valid/:user-invalidpseudo-classes are not consistently wired up for form-associated elements across browsers — verify support before relying on them rather than assuming they track user interaction the way they do for native controls.- Implement
formResetCallback,formDisabledCallback, andformStateRestoreCallbackso the element behaves like a native control on reset,fieldset[disabled], and back/forward autofill restore. InformDisabledCallback(disabled), disable internal controls (or remove them from the tab order and ignore input) rather than settingdisabledon the host — setting the host attribute inside the callback prevents re-enabling when an ancestor<fieldset>is re-enabled, and the host already matches:disabledautomatically. - Follow platform conventions for dispatched events and prefer the built-in
inputandchangeevents over custom ones.inputfires when the value changes as a direct result of user action (typing, dragging a slider);changefires once per stream of changes, when the user commits (releasing the slider, blurring the field). Construct them withInputEventandEventrespectively.
Fallback strategies
Feature-detect with static formAssociated support before relying on it: 'attachInternals' in HTMLElement.prototype && 'setFormValue' in ElementInternals.prototype. Where it is unavailable, fall back to a visually-hidden native <input> synced to the control's value so the form still submits — the one case where the hidden-input pattern is justified.