Skip to content

Form state

CSS can match a single invalid control with :invalid, but it can’t count invalid fields across a form or branch on “are they all valid yet?” — the two things you need for a progress bar or a submit gate. The form-state plugin aggregates a form’s constraint-validation state into readable numbers.

It’s the validity counterpart to field-state (interaction history) and the field plugin (per-field --live-valid): put field on a control, form-state on the form.

--live-valid-count --live-invalid-count --live-completion --live-all-valid

The bar is calc(var(--live-completion) * 100%); the status counts --live-invalid-count; the Submit button only enables under @container style(--live-all-valid: 1). CSS can match one :invalid field, but it can't count them or gate on "all valid" — that's what form-state adds.

Fill the fields with valid values and watch the bar fill, the count drop, and the Submit button enable. Per-field red/green borders are native :user-valid / :user-invalid; everything else is form-state.

Written on the bound element on the live cadence.

PropertyWhat it is
--live-field-countcontrols subject to constraint validation — excludes disabled, readonly, buttons, and hidden inputs (anything with willValidate === false)
--live-valid-counthow many of those currently pass validation
--live-invalid-counthow many currently fail
--live-all-valid1 when no invalid controls remain — your submit gate
--live-completion01: valid required controls ÷ required controls (1 if the form has no required fields) — a “how done is it” ratio

completion keys off required fields specifically (a required-but-empty field is invalid via valueMissing, so it doesn’t count as done), which makes it a meaningful progress signal even when a form has optional fields the user can skip.

<script type="module">import 'prop-for-that/auto'</script>
<!-- the form-state plugin loads itself the first time the attribute is seen -->
<form data-props-for="form-state">
<input name="name" required>
<input name="email" type="email" required>
<button type="submit">Save</button>
</form>
/* progress bar from the completion ratio */
.bar { scale: var(--live-completion) 1; transform-origin: left; }
/* a live "N to fix" count, no JS reading the form */
.errors::after { counter-reset: n calc(var(--live-invalid-count)); content: counter(n) ' to fix'; }
/* the submit gate — a discrete state, so read it with a style query */
button[type="submit"] { opacity: 0.5; pointer-events: none; }
@container style(--live-all-valid: 1) {
button[type="submit"] { opacity: 1; pointer-events: auto; }
}
import { propsFor, register } from 'prop-for-that'
import { formState } from 'prop-for-that/plugins'
register(formState)
propsFor(document.querySelector('form'), ['form-state'])

Validity recomputes on every input / change, and once more on the frame after a reset (so it reflects the reverted values). The disposer removes the listeners and the properties it wrote.