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.
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-class | Matches | Fires before interaction? |
|---|---|---|
:required / :optional | Presence of the required attribute | Yes, always accurate |
:valid / :invalid | Current constraint state | Yes — including on untouched fields |
:in-range / :out-of-range | Numeric/date bounds only | Yes, if constrained |
:user-valid / :user-invalid | Constraint state, after interaction | No — 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.
Tagged
Keep reading
Takina · · 4 min read CSS @starting-style Explained: Entry Transitions in CSS
CSS @starting-style defines an element's before-state on first render, so transitions can animate elements as they appear, even from display: none.
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.
Takina · · 4 min read CSS Relative Color Syntax: Deriving Colors from Colors
Relative color syntax lets CSS derive a new color from an existing one by adjusting its channels directly, no preprocessor or JS required.