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 = true on the class, and this.attachInternals() in the constructor (once per element).
  • A single sync point. Lit’s updated(changed) (or willUpdate) 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 input and change on the custom element; events from the inner input are composed and retargeted, but re-dispatching explicit input/change from the host makes the contract clear.
  • Accessible labelling. A <label for="my-field"> pointing at the host works for form-associated elements; with delegatesFocus the inner input receives focus when the label is clicked.
What ElementInternals gives a Lit control setFormValue makes the control's value appear in FormData and form submission. setValidity makes it take part in checkValidity, reportValidity and the invalid pseudo-class, with a message and an anchor. The labels property and label association let a label element name and focus the control, and internals ARIA properties set its role and states. Lifecycle callbacks let it respond to reset, disable and restore like a native input. setFormValue In FormData and submission. setValidity checkValidity, :invalid, messages. Labels and ARIA <label for> works. internals.ariaRequired etc. Lifecycle Reset, disable, restore callbacks.

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

  1. Declare formAssociated and attach internals once. Store the internals in a private field; calling attachInternals twice throws.
  2. Use delegatesFocus. Clicking the label or calling host.focus() focuses the inner input, which is what users and error summaries expect.
  3. Sync in updated. Every change that affects value or validity passes through one method, so FormData, :invalid and the rendered input never disagree.
  4. Delegate constraint checks to the inner input. Let the native input compute valueMissing, tooShort and typeMismatch, then mirror the flags with setValidity — no reimplementation of native rules.
  5. Accept external errors. An error-message attribute lets a framework or server set a custom error, which becomes customError — the route by which mapping 422 responses to field errors reaches a custom element.
  6. Expose the native validation API. validity, validationMessage, checkValidity and reportValidity on 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.

A keystroke through the Lit control The user types in the inner native input. Its input handler sets the host's value property and re-dispatches an input event from the host for frameworks. Lit schedules an update and re-renders. In updated, the changed value is passed to setFormValue with state, and validity is re-synced from the inner input's validity with setValidity. The form now sees the new value and validity immediately. Inner <input> input event Native editing, IME, autofill. The browser does the hard parts. host.value = e.target.value Re-dispatch input on the host. Frameworks listening on the element update their state. Lit updated() setFormValue(value, state) FormData and restoration stay current. setValidity from inner validity Flags + message + anchor. :invalid, checkValidity and summaries are correct.

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.

Symptoms of a missing piece If the field is missing from FormData, setFormValue is not called. If reportValidity ignores the field, setValidity is not called. If clicking the label does not focus the input, delegatesFocus is missing. If reset does not clear the value, formResetCallback is missing. If a React wrapper never sees changes, the host does not dispatch input or change events. Symptom Missing piece field missing from FormData setFormValue in updated() reportValidity ignores the field setValidity with an anchor label click does not focus delegatesFocus: true reset leaves the value formResetCallback framework state never updates host input/change events

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.


Related

← Web Components and Form Association