Skip to content

CSS styling

The controller does not ship styles. It only toggles boolean attributes on fields so your CSS can react. By default those attributes are css-filled and css-error. You can rename them with options.attributes.

This page shows how the attributes behave, which elements they apply to, and how to write CSS that pairs cleanly with them.

Attribute Added when Removed when
css-filled The field has a non-empty value (for checkboxes/radios, when checked; for files, when one or more files are selected). The field becomes empty.
css-error The field has at least one error — either from built-in validation rules or from setErrors. All errors for that field are cleared (via clearErrors, reset, or a successful re-validation).

The attributes are added and removed on:

  • Construction for css-filled, regardless of autoValidate. Initial css-error requires validation and therefore follows autoValidate.
  • Every delegated input event, including controls associated through form="id".
  • Every explicit call to validate, revalidate, setErrors, or clearErrors.
  • DOM mutations detected by the controller’s MutationObserver (new fields, changed name / type attributes).

reset() clears the visual and accessibility attributes first. If the controller has already validated, it then recalculates css-filled from native default values; error attributes remain absent until rules or manual errors add them again.

Initial filled-state synchronization is a visual concern, not validation. A server-rendered or browser-restored value therefore has css-filled immediately even with autoValidate: false; isValidated remains false and no validation error is created.

/* any field that has a value */
[css-filled] { /* ... */ }
/* any field with at least one error */
[css-error] { /* ... */ }

These attributes are boolean: present or absent. They never have a value. The selector [css-filled] matches both [css-filled] and [css-filled=""].

input[css-filled] {
border-color: var(--color-border-emphasis);
}
input[css-error] {
border-color: var(--color-danger);
background-color: var(--color-danger-soft);
}
input[css-error]:focus-visible {
outline-color: var(--color-danger);
}
textarea[css-filled] {
background-color: var(--color-surface-soft);
}
textarea[css-error] {
border-color: var(--color-danger);
}
select[css-filled] {
background-color: var(--color-surface-soft);
}
select[css-error] {
border-color: var(--color-danger);
}

The attribute is set on the input itself when it’s checked.

input[type="checkbox"][css-filled],
input[type="radio"][css-filled] {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
input[type="checkbox"][css-error],
input[type="radio"][css-error] {
outline: 2px solid var(--color-danger);
outline-offset: 2px;
}
input[type="file"][css-filled] {
border-color: var(--color-success);
}
input[type="file"][css-error] {
border-color: var(--color-danger);
}

A common pattern is to style a parent label that contains the input:

<label class="field">
<span>Email</span>
<input name="email" type="email" />
</label>
.field {
display: grid;
gap: 0.25rem;
}
.field:has([css-filled]) > span {
color: var(--color-text-emphasis);
}
.field:has([css-error]) > span {
color: var(--color-danger);
}
form('signup-form', {
attributes: { filled: 'is-filled', error: 'is-invalid' }
})
.is-filled { /* ... */ }
.is-invalid { /* ... */ }

The controller sets aria-invalid="true" while a field has validation or manual errors and removes it when the field becomes valid. Invalid submission focuses the first connected, enabled, non-hidden invalid field. Provide aria-describedby and an error message element in your template:

<label class="field">
<span>Email</span>
<input name="email" type="email" aria-describedby="email-error" />
<small id="email-error" hidden></small>
</label>

Subscribe to state so the message stays synchronized after input, submit, and manual-error changes:

profileForm.subscribe(state => {
emailErrorEl.hidden = !state.errors.email
emailErrorEl.textContent = state.errors.email?.[0] ?? ''
})

See Validation and accessible errors for labels, summaries, live announcements, and focus behavior in one complete example.

For repeated controls validated with each, css-error and aria-invalid are applied only to the concrete members that fail. Group-level rules and manual errors apply to every concrete control for that field name. The public FormErrors entry remains a flat string[], so render item-specific text using your own index-aware markup when needed.

  • The attributes have no value. A selector like [css-filled="true"] will not match. Use [css-filled] instead.
  • css-error is set whenever there is any error, including manual errors from setErrors. Combine your selector with the actual message you render.
  • Field labels do not automatically receive the attributes. Apply styles to the input itself, or use :has() / a wrapper class.
  • CSS is not the only place the attributes appear. Treat them as a styling hook, not as state. Read getState() if you need the authoritative state.