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.
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.
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()
Grouping Related Fields Into One Object
For forms with many fields, tracking each one as a separate @state() property becomes repetitive. Grouping them into a single object (updated immutably, exactly like any other object-valued reactive property) keeps the component's state more manageable.
The spread ({ ...this.formData, [field]: value }) creates a new object reference, which is required for Lit's default change detection to notice the update.
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.
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.
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.
You now know how to build form UI with Lit. Next, learn about form-associated custom elements for deeper native form integration.