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:

  • Mount (initial sync).
  • Every input / change event delegated at the form level.
  • Every explicit call to validate, revalidate, setErrors, or clearErrors.
  • DOM mutations detected by the controller’s MutationObserver (new fields, changed name / type attributes).

The attributes are also removed everywhere when you call reset.

/* 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 does not set ARIA attributes. If you want screen-reader friendly error feedback, pair the visual attributes with aria-invalid and aria-describedby from your own template:

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

When you surface validation messages, toggle aria-invalid and reveal the error element. The controller’s getState() returns the current errors per field so you can drive this from a single source of truth:

const state = profileForm.getState()
emailInput.setAttribute('aria-invalid', state.errors.email ? 'true' : 'false')
emailErrorEl.hidden = !state.errors.email
emailErrorEl.textContent = state.errors.email?.[0] ?? ''
  • 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.