Skip to content

Chapter 49: Forms and Form-Associated Components

A form component isn’t complete because it looks like a field.

It has to participate in the form. The browser’s form-submission machinery has to see the component’s value. The constraint-validation API has to be able to mark the component invalid. The <fieldset disabled> pattern has to disable the component along with everything else in the fieldset. The form reset has to reset the component. The accessibility tree has to recognize the component as a form control with a proper label.

For most custom UI components, none of this is automatic. The component renders correctly, looks like a field, but is invisible to the form. The form submits without the component’s value. The constraint validation skips the component. The user fixes their input but the form still tells them something’s missing.

The platform’s answer is form-associated custom elements, which Chapter 24 introduced. This chapter builds the components — kit-field for composition, kit-text-field, kit-checkbox, kit-radio-group — that participate in forms as first-class members.

The platform’s form-control protocol has several pieces a custom element has to opt into.

Value participation. The component’s current value appears in FormData when the form is submitted. The browser’s submission machinery iterates the form’s elements, asks each one for its name and value, and assembles the payload. A form-associated custom element calls internals.setFormValue(value) to declare its value; the browser includes it.

Constraint validation. The component can declare itself valid or invalid through internals.setValidity(...). The browser uses this for the form’s overall validity check; submitting an invalid form blocks the submission and focuses the first invalid field. The CSS :invalid and :user-invalid pseudo-classes apply.

Form reset. When the form is reset (via the user’s reset button or form.reset()), each form-associated element receives a formResetCallback() that lets it restore its default state.

Disabled state. The <fieldset disabled> pattern disables every form control inside. A form-associated custom element receives formDisabledCallback(disabled) when its enclosing fieldset’s disabled state changes; the element should respond by visually showing the disabled state and refusing user interaction.

Form state restoration. When the user reloads the page or navigates with the back button, the browser may restore form values (browser autofill, certain navigation patterns). A form-associated element can opt into this through formStateRestoreCallback().

Labelled-by association. A <label> element’s for attribute, pointing at the custom element’s id, makes the label associate with the element. Clicking the label focuses the element. The label text becomes the element’s accessible name.

These pieces compose. A complete form-associated component implements all of them; the result is a custom element that’s indistinguishable, from the form’s perspective, from a native form control.

The simplest piece is a layout wrapper that composes a label, an optional description, the control, and an optional error message:

class KitField extends LitElement {
@property({ type: String }) label = ''
@property({ type: String }) description = ''
@property({ type: String }) error = ''
@property({ type: Boolean, reflect: true }) required = false
static styles = css`
:host { display: block; margin-bottom: 1rem; }
label {
display: block;
font-weight: 500;
margin-bottom: 0.25rem;
}
.required-marker {
color: var(--kit-color-danger, #d04444);
margin-left: 0.25em;
}
.description {
font-size: 0.875rem;
color: var(--kit-color-subtle, rgba(0,0,0,0.6));
margin-bottom: 0.25rem;
}
.error {
color: var(--kit-color-danger, #d04444);
font-size: 0.875rem;
margin-top: 0.25rem;
}
`
render() {
return html`
<label>
${this.label}
${this.required ? html`<span class="required-marker" aria-hidden="true">*</span>` : ''}
</label>
${this.description ? html`<div class="description">${this.description}</div>` : ''}
<slot></slot>
${this.error ? html`<div class="error" role="alert">${this.error}</div>` : ''}
`
}
}
customElements.define('kit-field', KitField)

Usage:

<form>
<kit-field label="Display name" description="Shown publicly on your profile." required>
<input name="displayName" required>
</kit-field>
<kit-field label="Bio">
<textarea name="bio" maxlength="500"></textarea>
</kit-field>
<kit-button type="submit">Save</kit-button>
</form>

The wrapper doesn’t replace the native input. The <input> and <textarea> are real form controls. They submit their values to the form natively. The wrapper provides the visual structure — label, description, error display — without getting in the way of the form’s behavior.

For most form needs, this is the right pattern. A kit-field plus a native form control is a complete form row, with full form participation, accessibility, and validation.

kit-text-field: A Form-Associated Custom Element

Section titled “kit-text-field: A Form-Associated Custom Element”

For cases where the application needs a custom field — rich validation feedback, integrated icons, complex behavior — the form-associated custom element pattern is the right tool.

A full example, kit-text-field:

class KitTextField extends LitElement {
static formAssociated = true
@property({ type: String }) name = ''
@property({ type: String, reflect: true }) value = ''
@property({ type: String }) type: 'text' | 'email' | 'password' | 'url' = 'text'
@property({ type: Boolean, reflect: true }) required = false
@property({ type: Boolean, reflect: true }) disabled = false
@property({ type: String }) pattern = ''
@property({ type: String }) placeholder = ''
@property({ type: Number }) minlength?: number
@property({ type: Number }) maxlength?: number
private internals: ElementInternals
private defaultValue = ''
constructor() {
super()
this.internals = this.attachInternals()
}
connectedCallback() {
super.connectedCallback()
this.defaultValue = this.getAttribute('value') ?? ''
this.internals.setFormValue(this.value)
this.updateValidity()
}
static styles = css`
:host { display: block; }
input {
width: 100%;
padding: 0.5em 0.75em;
border: 1px solid var(--kit-border, rgba(0,0,0,0.2));
border-radius: 4px;
font: inherit;
background: var(--kit-surface, white);
color: inherit;
box-sizing: border-box;
}
input:focus-visible {
outline: 2px solid var(--kit-color-focus, currentColor);
outline-offset: 1px;
}
:host([disabled]) input {
opacity: 0.5;
cursor: not-allowed;
}
:host(:state(invalid)) input {
border-color: var(--kit-color-danger, #d04444);
}
`
render() {
return html`
<input
type=${this.type}
.value=${this.value}
?disabled=${this.disabled}
?required=${this.required}
pattern=${this.pattern || nothing}
placeholder=${this.placeholder}
minlength=${this.minlength ?? nothing}
maxlength=${this.maxlength ?? nothing}
@input=${this.handleInput}
@change=${this.handleChange}
/>
`
}
private handleInput(event: Event) {
const input = event.target as HTMLInputElement
this.value = input.value
this.internals.setFormValue(this.value)
this.updateValidity()
this.dispatchEvent(new CustomEvent('kit-input', {
bubbles: true,
composed: true,
detail: { value: this.value }
}))
}
private handleChange(event: Event) {
this.dispatchEvent(new CustomEvent('kit-change', {
bubbles: true,
composed: true,
detail: { value: this.value }
}))
}
private updateValidity() {
const inner = this.shadowRoot?.querySelector('input')
if (!inner) return
if (inner.validity.valid) {
this.internals.setValidity({})
this.internals.states.delete('invalid')
} else {
this.internals.setValidity(
inner.validity,
inner.validationMessage,
inner
)
this.internals.states.add('invalid')
}
}
// Form-associated lifecycle callbacks
formResetCallback() {
this.value = this.defaultValue
this.internals.setFormValue(this.value)
this.updateValidity()
}
formDisabledCallback(disabled: boolean) {
this.disabled = disabled
}
formStateRestoreCallback(state: string) {
this.value = state
this.internals.setFormValue(this.value)
}
checkValidity(): boolean {
return this.internals.checkValidity()
}
reportValidity(): boolean {
return this.internals.reportValidity()
}
}
customElements.define('kit-text-field', KitTextField)

The implementation is around 120 lines. The behaviors it provides:

  • Form participation. The component’s value appears in FormData via setFormValue. The form’s submit collects it.
  • Constraint validation. The component forwards the inner input’s validity state to its own internals. The form’s overall validity check sees the component as valid or invalid correctly.
  • State-based styling. The :state(invalid) CSS selector (Custom State Pseudo-Classes, shipped 2023–2024) lets the component declare custom states the application’s CSS can target.
  • Reset support. formResetCallback restores the default value when the form is reset.
  • Disabled inheritance. formDisabledCallback propagates the disabled state from an enclosing <fieldset disabled>.
  • State restoration. formStateRestoreCallback lets the component recover its state if the browser restores form values after navigation.
  • Labelling. A <label for="my-field"> outside the component (or a <label> wrapping it) associates with the component for accessible name calculation, click-to-focus, and screen-reader navigation.

Usage:

<form data-meta-event="signup.attempted">
<label for="email-field">Email</label>
<kit-text-field
id="email-field"
name="email"
type="email"
required
placeholder="you@example.com"
></kit-text-field>
<label for="password-field">Password</label>
<kit-text-field
id="password-field"
name="password"
type="password"
required
minlength="8"
></kit-text-field>
<kit-button type="submit" variant="primary">Sign up</kit-button>
</form>

When the user submits, the form’s submit event fires. Constraint validation runs — empty email gets caught, password under 8 characters gets caught, invalid email format gets caught. If validation passes, FormData includes email and password from the custom elements as if they were native inputs. The metadata boundary observes the submit and fires signup.attempted into the runtime.

The pattern extends to other form controls. kit-checkbox:

class KitCheckbox extends LitElement {
static formAssociated = true
@property({ type: String }) name = ''
@property({ type: String }) value = 'on'
@property({ type: Boolean, reflect: true }) checked = false
@property({ type: Boolean, reflect: true }) disabled = false
@property({ type: Boolean, reflect: true }) required = false
private internals: ElementInternals
constructor() {
super()
this.internals = this.attachInternals()
}
connectedCallback() {
super.connectedCallback()
this.internals.setFormValue(this.checked ? this.value : null)
}
render() {
return html`
<input
type="checkbox"
.checked=${this.checked}
?disabled=${this.disabled}
?required=${this.required}
@change=${this.handleChange}
/>
<slot></slot>
`
}
private handleChange(event: Event) {
this.checked = (event.target as HTMLInputElement).checked
this.internals.setFormValue(this.checked ? this.value : null)
this.dispatchEvent(new CustomEvent('kit-change', {
bubbles: true,
composed: true,
detail: { checked: this.checked }
}))
}
}
customElements.define('kit-checkbox', KitCheckbox)

Note that the form value is null when unchecked. The browser’s form submission excludes a control whose value is null, matching the native checkbox’s behavior (an unchecked checkbox doesn’t appear in FormData).

kit-radio-group is more involved because radio groups have shared state (only one option in the group can be selected). The pattern is to have the group itself be the form-associated element, with the individual options as inner buttons that the group manages:

class KitRadioGroup extends LitElement {
static formAssociated = true
@property({ type: String }) name = ''
@property({ type: String, reflect: true }) value = ''
private internals: ElementInternals
constructor() {
super()
this.internals = this.attachInternals()
this.addEventListener('click', this.handleOptionClick)
}
connectedCallback() {
super.connectedCallback()
this.setAttribute('role', 'radiogroup')
this.internals.setFormValue(this.value)
}
render() {
return html`<slot></slot>`
}
private handleOptionClick = (event: Event) => {
const option = (event.target as Element).closest('kit-radio-option')
if (!option) return
const value = option.getAttribute('value')
if (!value) return
this.value = value
this.internals.setFormValue(value)
// Update all options' checked state
this.querySelectorAll('kit-radio-option').forEach((opt) => {
opt.setAttribute('aria-checked', String(opt.getAttribute('value') === value))
})
this.dispatchEvent(new CustomEvent('kit-change', {
bubbles: true,
composed: true,
detail: { value }
}))
}
}

The pattern is the same: form association, constraint validation, custom-state styling, composed events. The component handles the radio-group-specific behavior (single-selection, keyboard navigation between options) while remaining a real form control.

Form-associated custom elements participate in the platform’s form-submission machinery. The metadata-boundary listener observes the form’s submit event (Chapter 42) and dispatches it into the runtime. Modules subscribed to the form’s event receive the form’s data:

const signupModule = defineKitModule({
name: 'signup',
events: {
'signup.attempted': async (event) => {
const { email, password } = (event.payload as any).data
// Validate at the boundary
if (!isValidEmail(email)) {
runtime.emit({
type: 'form.validation_failed',
context: event.context,
payload: { field: 'email', message: 'Invalid email' }
})
return
}
// Submit
try {
const result = await signupRepo.signup({ email, password })
runtime.emit({ type: 'signup.succeeded', payload: result })
} catch (err) {
runtime.emit({ type: 'signup.failed', payload: { error: err } })
}
}
}
})

The form participates in validation at two levels — the platform’s constraint validation (native, declarative, run by the browser) and the application’s business validation (in modules, runtime-routed, with semantic events). Both layers cooperate. The first catches obvious errors before the form submits. The second handles application-specific rules.

The next chapter (Chapter 50) covers styling — design tokens, cascade layers, container queries applied to the Kit component library. The patterns from this chapter and the previous ones get unified into a consistent visual system. Chapter 51 then ties everything together with the design-systems argument.

Implement kit-field, kit-text-field, and kit-checkbox from the patterns in this chapter.

Then build a complete signup form:

<form data-meta-event="signup.attempted">
<kit-field label="Display name" required>
<kit-text-field name="displayName" required minlength="3"></kit-text-field>
</kit-field>
<kit-field label="Email" required>
<kit-text-field name="email" type="email" required></kit-text-field>
</kit-field>
<kit-field label="Password" description="At least 8 characters" required>
<kit-text-field name="password" type="password" required minlength="8"></kit-text-field>
</kit-field>
<kit-field>
<kit-checkbox name="newsletter" value="yes">
Send me product updates
</kit-checkbox>
</kit-field>
<kit-button type="submit" variant="primary">Sign up</kit-button>
</form>

Test the form-participation behaviors:

  1. Constraint validation: Try to submit without filling required fields. Verify the browser blocks the submission, focuses the first invalid field, and shows validation messages.
  2. FormData: When the form submits, log Object.fromEntries(new FormData(form)). Verify each kit-text-field and kit-checkbox value appears.
  3. Form reset: Add a reset button. Verify the kit-text-fields return to their default values and the kit-checkbox returns to unchecked.
  4. Disabled fieldset: Wrap the form in <fieldset disabled>. Verify all the Kit components show as disabled and don’t accept input.
  5. Custom validity: Use kitTextField.setCustomValidity('Some message') (if exposed) to mark a field invalid programmatically. Verify the form’s submission is blocked.
  6. Accessibility: Use VoiceOver/NVDA. Verify each field is announced with its label, and the password field’s description (At least 8 characters) is read.

Then integrate with the architecture:

  1. Wire the form’s data-meta-event="signup.attempted" to a signup module.
  2. The module receives the form’s data through the event payload.
  3. The module performs business validation and emits semantic events for failures (signup.email_taken, signup.password_too_weak) or successes (signup.succeeded).
  4. A UI module subscribes to the failure events and displays the appropriate error messages by setting the relevant kit-field’s error property.

Reflect on:

  1. How much of the form’s behavior came from the platform? (Constraint validation, label-input association, focus management, FormData assembly, submit-on-Enter.)
  2. How much from Lit? (Reactive properties, templating, scoped styles.)
  3. How much from ElementInternals? (Form value participation, validity propagation, reset handling, disabled inheritance.)
  4. How much from the application’s modules? (Business validation, UI feedback for application-specific failures.)
  5. If the kit-text-field had to be replaced with a native <input> tomorrow, how much application code would change? (The kit-field wrapper would still work; the architecture’s other pieces wouldn’t change.)

The form story is one of the platform’s most-developed surfaces. The decoration-versus-replacement principle pays off particularly clearly here. The Kit components add ergonomics without sacrificing the platform’s form contracts. The applications that need custom field semantics get them as first-class form participants, not as second-class custom DOM that the form’s submit ignores.