Design systems built with Lit often ship inputs that look right and fail in forms: new FormData(form) omits them, form.reportValidity() ignores them, a <label for> does not focus them, and a React or Vue wrapper has to manually read .value on submit because the element is invisible to the form.
ElementInternals fixes all of that, and Lit’s reactive properties make the wiring straightforward — as long as every property change that affects the value or validity flows into setFormValue and setValidity in one place. This page builds a form-associated text field in Lit, as a concrete application of form-associated custom elements with ElementInternals within web components and form association.
Context and prerequisites
A form-associated Lit element needs:
static formAssociated = trueon the class, andthis.attachInternals()in the constructor (once per element).- A single sync point. Lit’s
updated(changed)(orwillUpdate) is where property changes are known; syncing there keeps form value and validity consistent with rendered state. - An internal native input inside the shadow root, for the actual text editing, IME, autofill of the inner control and mobile keyboards.
- Events on the host. Frameworks bind
inputandchangeon the custom element; events from the inner input are composed and retargeted, but re-dispatching explicitinput/changefrom the host makes the contract clear. - Accessible labelling. A
<label for="my-field">pointing at the host works for form-associated elements; withdelegatesFocusthe inner input receives focus when the label is clicked.
The core pattern: a Lit text field with one sync point
import { LitElement, html, css, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators.js";
@customElement("ds-text-field")
export class DsTextField extends LitElement {
static formAssociated = true;
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
static styles = css`:host { display: block; } input { font: inherit; width: 100%; }`;
#internals = this.attachInternals();
@property() name = "";
@property() value = "";
@property({ attribute: "value", reflect: false }) defaultValue = ""; // native-style default
@property({ type: Boolean, reflect: true }) required = false;
@property({ type: Number }) minlength?: number;
@property() autocomplete = "";
@property({ attribute: "error-message" }) errorMessage = ""; // server/app-provided error
@query("input") private input!: HTMLInputElement;
// ONE sync point: whenever value or constraints change, update the form.
protected updated(changed: PropertyValues<this>) {
if (changed.has("value")) this.#internals.setFormValue(this.value, this.value);
if (["value", "required", "minlength", "errorMessage"].some((k) => changed.has(k as keyof this))) {
this.#syncValidity();
}
this.#internals.ariaRequired = String(this.required);
}
#syncValidity() {
// Let the inner native input evaluate its own constraints, then mirror them.
const v = this.input?.validity;
if (this.errorMessage) {
this.#internals.setValidity({ customError: true }, this.errorMessage, this.input);
} else if (v && !v.valid) {
this.#internals.setValidity(
{ valueMissing: v.valueMissing, tooShort: v.tooShort, typeMismatch: v.typeMismatch },
this.input.validationMessage, this.input);
} else {
this.#internals.setValidity({});
}
}
#onInput(e: Event) {
this.value = (e.target as HTMLInputElement).value;
// Re-dispatch from the host so frameworks see a plain, non-composed event on the element they bound.
this.dispatchEvent(new Event("input", { bubbles: true }));
}
formResetCallback() { this.value = this.defaultValue; }
formDisabledCallback(disabled: boolean) { this.toggleAttribute("disabled", disabled); this.requestUpdate(); }
formStateRestoreCallback(state: string | null) { if (typeof state === "string") this.value = state; }
get validity() { return this.#internals.validity; }
get validationMessage() { return this.#internals.validationMessage; }
checkValidity() { return this.#internals.checkValidity(); }
reportValidity() { return this.#internals.reportValidity(); }
render() {
return html`<input
.value=${this.value}
?required=${this.required}
minlength=${this.minlength ?? ""}
autocomplete=${this.autocomplete || "off"}
?disabled=${this.hasAttribute("disabled")}
@input=${this.#onInput}
@change=${() => this.dispatchEvent(new Event("change", { bubbles: true }))} />`;
}
}
<form>
<label for="email">Email</label>
<ds-text-field id="email" name="email" required autocomplete="email"></ds-text-field>
<button>Save</button>
</form>
Step-by-step walkthrough
- Declare
formAssociatedand attach internals once. Store the internals in a private field; callingattachInternalstwice throws. - Use
delegatesFocus. Clicking the label or callinghost.focus()focuses the inner input, which is what users and error summaries expect. - Sync in
updated. Every change that affects value or validity passes through one method, soFormData,:invalidand the rendered input never disagree. - Delegate constraint checks to the inner input. Let the native input compute
valueMissing,tooShortandtypeMismatch, then mirror the flags withsetValidity— no reimplementation of native rules. - Accept external errors. An
error-messageattribute lets a framework or server set a custom error, which becomescustomError— the route by which mapping 422 responses to field errors reaches a custom element. - Expose the native validation API.
validity,validationMessage,checkValidityandreportValidityon the host make the element usable by code written for native inputs.
Why the inner input still matters
It might seem cleaner to make the host itself editable — contenteditable, or custom key handling — and skip the inner <input>. That discards a great deal the browser does for free: IME composition for Chinese, Japanese and Korean input, mobile keyboard types from inputmode, spellcheck, undo history, text selection and password-manager integration. Keeping a real input inside the shadow root and treating the custom element as a wrapper that reports to the form is less code and far more robust. ElementInternals exists precisely so the wrapper can stand in for the input in the form’s eyes.
Failure modes and edge cases
1. Validity checked before first render
this.input is undefined until the first render. Guard with optional chaining, or run the first validity sync in firstUpdated, otherwise a required field is briefly “valid” and a submit in that window passes.
2. Framework wrappers and property vs attribute
React 19 sets custom element properties when they exist on the element; earlier React versions set attributes only. Vue sets properties when defined. Keep value a property (not only an attribute) and reflect only what must be in the DOM, such as required for styling.
3. Autofill of the inner input
Browsers autofill the inner input when it has an autocomplete token and is visible to the autofill heuristics. The input event from autofill reaches #onInput, so the host value updates — verify with a saved address in each browser, because autofill inside shadow DOM has had inconsistencies across versions.
4. Server-side rendering
Lit SSR renders the shadow DOM declaratively, but ElementInternals does not exist on the server. Guard attachInternals for SSR environments, and rely on the element hydrating before submit — or render a hidden native input fallback for no-JS submission.
5. Label association across shadow boundaries
<label for="email"> targets the host, which works because the host is form-associated and labelable. A label inside the shadow root cannot label an input in the light DOM, and ARIA ids do not cross shadow boundaries — see labels and delegatesFocus for shadow DOM inputs.
Verification checklist
Frequently Asked Questions
Should validation messages come from the browser or from my design system?
Use the inner input’s native checks for the flags, but supply your own message text, so wording is consistent across browsers and locales. The native validationMessage is a reasonable fallback during development.
Can I use this element inside React Hook Form or VeeValidate?
Yes, as a controlled component through the framework’s controller API (Controller in React Hook Form, useField in VeeValidate), binding value and listening for input. Because the element is form-associated, native FormData submission also works without the library.
Is ElementInternals supported in all browsers?
It is supported in current Chromium, Firefox and Safari. For older browsers, a polyfill exists, or you can render a hidden native input in the light DOM as a fallback for submission only.