Articles

CSS Form Validation Pseudo-Classes Explained

CSS pseudo-classes like :valid, :invalid, :required, and :user-invalid style form feedback natively, without JavaScript watching every keystroke.

Takina Takina · · 4 min read
Close-up of CSS code on a screen

CSS form validation pseudo-classes — :valid, :invalid, :required, :optional, :in-range, :out-of-range, and :user-invalid — let you style form feedback based on the browser’s own constraint validation, without writing a line of JavaScript. The browser already knows whether an <input type="email"> contains something shaped like an email address, or whether a required field is empty; these selectors just expose that knowledge to your stylesheet.

The core pair: :valid and :invalid

Every form control that participates in constraint validation matches either :valid or :invalid at all times, based on its current value and its validation attributes (required, type, pattern, min, max, minlength, maxlength).

input:invalid {
  border-color: crimson;
}

input:valid {
  border-color: seagreen;
}

The catch is that an empty, untouched required field is also :invalid — it hasn’t been filled in yet, so it fails the constraint. Applying red borders to :invalid unconditionally means every required field on the page looks like an error before the user has typed anything, which is worse than no validation styling at all.

:required and :optional

These two match based on the presence of the required attribute, independent of whether the value is currently valid:

label:has(+ input:required)::after {
  content: " *";
  color: crimson;
}

That example leans on the :has() relational selector to style a label based on a sibling input’s attribute — a pattern that was impossible in CSS before :has() shipped, and previously required JavaScript just to add an asterisk.

:in-range and :out-of-range

For numeric and date inputs with min and max attributes, :in-range and :out-of-range track whether the current value falls within those bounds:

input[type="number"]:out-of-range {
  outline: 2px solid darkorange;
}

Unlike :valid/:invalid, these only apply to inputs that actually have range constraints — a plain <input type="number"> with no min or max never matches either one.

The fix for premature red borders: :user-invalid

The real problem with :invalid alone is timing: it fires the instant a required field renders, before the user has had a chance to interact with it. :user-invalid (and its counterpart :user-valid) solve this by only matching after the user has interacted with the field and then left it in an invalid state — typically after a blur following some input.

input:user-invalid {
  border-color: crimson;
  background: #fff0f0;
}

input:user-valid {
  border-color: seagreen;
}

This one change — swapping :invalid for :user-invalid in your error styling — is usually enough to fix the “every field is red on page load” problem that makes plain :invalid unpleasant to use directly. It mirrors how frameworks have long tracked a touched or dirty flag in JavaScript form libraries, except the browser now tracks it natively.

Styling the whole form based on a field’s state

Combine these pseudo-classes with :has() to style a <label>, a wrapping <div>, or even the whole <form> based on the state of a descendant field:

.field:has(input:user-invalid) {
  color: crimson;
}

.field:has(input:user-invalid) small.error-text {
  display: block;
}

This pattern replaces a common chunk of JavaScript that used to exist purely to toggle an error class on a wrapper element in response to input and blur events. The logic still runs — it’s just running inside the browser’s constraint validation engine instead of your event listeners.

A practical example

.field input {
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 0.5rem;
}

.field input:user-valid {
  border-color: seagreen;
}

.field input:user-invalid {
  border-color: crimson;
}

.field small.hint {
  display: none;
  color: crimson;
  font-size: 0.85rem;
}

.field:has(input:user-invalid) small.hint {
  display: block;
}
<div class="field">
  <label for="email">Email *</label>
  <input id="email" type="email" required />
  <small class="hint">Enter a valid email address.</small>
</div>

No JavaScript is involved in any of this — the hint text appears only once the user has typed something invalid and moved on, and disappears again once the value becomes valid.

Comparison: which pseudo-class for which job

Pseudo-classMatchesFires before interaction?
:required / :optionalPresence of the required attributeYes, always accurate
:valid / :invalidCurrent constraint stateYes — including on untouched fields
:in-range / :out-of-rangeNumeric/date bounds onlyYes, if constrained
:user-valid / :user-invalidConstraint state, after interactionNo — waits for user input

Where this fits with accessibility and JS validation

Native validation pseudo-classes complement, rather than replace, good form accessibility practice — a red border alone isn’t enough; pair it with aria-invalid and a visible, programmatically associated error message. They also don’t replace server-side validation: constraint attributes and pseudo-classes are trivially bypassed by anyone submitting the form outside a browser, so the server must still validate every field independently, the same way you’d never trust client-side input validation alone for security-sensitive data.

For validation logic more complex than pattern-matching — cross-field checks like “confirm password must match password,” or async checks like “this username is taken” — you still need JavaScript and the Constraint Validation API’s setCustomValidity(). The pseudo-classes handle the common, purely-declarative cases so your JavaScript only has to handle the rest.

The takeaway

CSS’s form validation pseudo-classes let the browser’s own constraint validation drive your styling: :required/:optional describe what a field demands, :valid/:invalid describe its current state, and :user-invalid/:user-valid add the missing timing dimension so errors don’t appear before the user has done anything. Combined with :has(), they cover most everyday form feedback without a single event listener — leaving JavaScript free for the validation rules that genuinely need it.

Takina Takina · · 4 min read

Container Query Units: cqw, cqh, and cqi Explained

Container query units like cqw and cqi size elements relative to their containing element, not the viewport — here's how and when to use them.

#CSS #Web Development #Frontend