Skip to content

Lit Forms

Forms built with Lit combine everything covered so far — property bindings, events, and reactive state. This lesson covers the practical patterns for building form UI with plain Lit components.

Binding Form Input Values

The standard pattern for a controlled form field in Lit is a property binding (.value=${...}) paired with an @input (or @change) event listener that updates a reactive property — the same 'controlled input' idea found in other component-based UI approaches, expressed with Lit's own binding syntax.

For simple forms contained entirely within one component, storing each field's value as a @state() property and reading them all on submit is usually enough. For more complex, multi-step, or deeply nested forms, consider grouping related fields into a single object property instead of many separate ones.

class SignupForm extends LitElement {
  @state() private email = '';
  @state() private password = '';

  render() {
    return html`
      <form @submit=${this.#onSubmit}>
        <input type="email" .value=${this.email} @input=${(e) => (this.email = e.target.value)} required />
        <input type="password" .value=${this.password} @input=${(e) => (this.password = e.target.value)} required />
        <button type="submit">Sign Up</button>
      </form>
    `;
  }

  #onSubmit(event) {
    event.preventDefault();
    this.dispatchEvent(new CustomEvent('signup', { detail: { email: this.email, password: this.password } }));
  }
}

Each field is a controlled input bound to its own reactive property; submission is intercepted with preventDefault() and re-dispatched as a custom event with the collected data.

Common Form Binding Patterns

.value=${this.field} @input=${(e) => (this.field = e.target.value)}   // text input
?checked=${this.agreed} @change=${(e) => (this.agreed = e.target.checked)} // checkbox
.value=${this.choice} @change=${(e) => (this.choice = e.target.value)}    // select
  • Text-like inputs use .value (a property binding) rather than the value attribute, so updates work reliably in both directions.
  • Checkboxes and radio buttons use ?checked (a boolean attribute binding) and read event.target.checked in the handler.
  • <select> elements bind the same way as text inputs, using .value and reading event.target.value.
  • Native browser form validation (required, pattern, type="email") still works normally on inputs rendered by Lit templates.

Form Binding Cheat Sheet

The binding pattern for each common form control type.

Control Binding
Text/email/password input .value=${field} + @input
Checkbox ?checked=${field} + @change, read .checked
Radio buttons ?checked=${field === value} per radio + @change
Select dropdown .value=${field} + @change
Textarea .value=${field} + @input
Submit handling @submit=${handler} with event.preventDefault()

Tracking Validation State

Beyond native HTML validation attributes, you'll often want reactive validation state to show custom error messages. Store validity as its own @state() field, computed on submit or on blur, rather than recomputing it inline inside render() on every keystroke unless that's genuinely the desired UX.

@state() private errors: Record<string, string> = {};

#validate() {
  const errors = {};
  if (!this.formData.email.includes('@')) errors.email = 'Enter a valid email';
  this.errors = errors;
  return Object.keys(errors).length === 0;
}

#onSubmit(event) {
  event.preventDefault();
  if (this.#validate()) {
    this.dispatchEvent(new CustomEvent('form-submit', { detail: this.formData }));
  }
}

Accessible Form Markup

Every form control needs an associated, visible label — either wrapping the input, or connected via a matching for/id pair, or (less ideally) aria-label. Since Lit renders into Shadow DOM, for/id pairs work fine as long as both the <label> and its input live inside the *same* shadow root; they cannot reach across separate shadow roots.

Group related fields (like a set of radio buttons) inside a <fieldset> with a <legend> describing the group, and connect error messages to their field with aria-describedby so assistive technology announces the error alongside the field itself.

<label for="email">Email address</label>
<input id="email" type="email" aria-describedby=${this.errors.email ? 'email-error' : undefined} />
${this.errors.email ? html`<p id="email-error" role="alert">${this.errors.email}</p>` : nothing}
  • Always pair every input with a real, visible <label> — never rely on placeholder text alone as a label substitute.
  • Keep <label for>/id pairs within the same shadow root; they don't work across separate Shadow DOM boundaries.
  • Use aria-describedby to connect validation error messages to their corresponding field.
  • Use <fieldset>/<legend> for logically grouped controls like a set of radio buttons.

Common Mistakes

  • Using the value attribute instead of the .value property binding, which can desync from the actual DOM state after user typing.
  • Relying only on placeholder text as a substitute for a real <label>, which fails accessibility requirements.
  • Mutating a grouped form-data object in place instead of creating a new object reference on each update.
  • Forgetting event.preventDefault() on form submission, causing an unwanted full-page reload.

Key Takeaways

  • Controlled form fields bind .value/?checked to a reactive property and update it via @input/@change.
  • Grouping related fields into one object property keeps larger forms' state manageable.
  • Custom validation state should live in its own reactive property, updated deliberately (e.g. on submit or blur).
  • Every form control needs a real, associated label, and for/id pairs must stay within the same shadow root.

Pro Tip

Test every form you build using only the keyboard (Tab, Shift+Tab, Enter, Space) before considering it done — it's the fastest way to catch missing labels, broken focus order, and controls that silently don't respond to keyboard interaction.