CSS Validation State Styling
A surprising amount of form validation UI no longer needs JavaScript at all. Modern CSS can tell whether a field is required, whether its value is valid, whether the user has interacted with it yet, and — with :has() — whether any field inside a group, a label’s control or an entire form is currently invalid. Used well, these selectors remove whole categories of script: toggling error classes, adding is-dirty flags, repainting labels when their input changes. Used badly, they paint every required field red on page load, rely on colour alone, and disappear entirely in Windows High Contrast. This topic covers the validation pseudo-classes and patterns that make CSS a reliable partner to the site’s canonical <form novalidate> plus reportValidity() flow from the Constraint Validation API — with JavaScript still owning the messages, and CSS owning the look.
The division of labour is simple and worth stating up front. The browser computes validity — from native attributes and from any custom rule you report with setCustomValidity(). Script writes the words — error messages, hints, summaries — and moves focus. CSS decides how every state looks, reading validity through pseudo-classes rather than through classes your script has to remember to add and remove. When each layer sticks to its job, the styling can never disagree with the actual validity of the form.
The failure this topic prevents is the “angry form”: a page that greets users with red borders on every empty required field before they have typed a character, because it styled :invalid instead of :user-invalid.
Prerequisites for CSS Validation Styling
| Requirement | Minimum version | Why it is needed |
|---|---|---|
:user-invalid / :user-valid |
Chrome 119, Firefox 88, Safari 16.5 | Styling only after interaction |
:has() |
Chrome 105, Firefox 121, Safari 15.4 | Styling groups, labels and forms from their fields |
@supports selector(…) |
All evergreen browsers | Fallbacks for older engines |
forced-colors media query |
Chrome 89, Firefox 89, Safari 16 | High-contrast adjustments |
prefers-reduced-motion |
All evergreen browsers | Calming validation animations |
| Script for messages | — | CSS cannot write the error text; setCustomValidity + described-by does |
Validation Selector Reference
| Selector | Matches | Typical use | Caveat |
|---|---|---|---|
input:user-invalid |
Invalid after the user changed it, left it, or submitted | Error border, icon | Browsers differ slightly on exactly when “interaction” counts |
input:user-valid |
Valid after interaction | Success tick for complex fields | Use sparingly — not every field needs a tick |
input:invalid |
Invalid right now | Almost never for visuals | Matches empty required fields on load |
:required |
Has required |
Required indicator in the label | Pair with text, not only * |
label:has(+ input:user-invalid) |
Label before an invalid input | Colour the label text | Order-dependent sibling combinator |
.field:has(:user-invalid) |
Wrapper containing an invalid control | Whole-field error styling | Works for any markup order |
form:has(:user-invalid) .submit-hint |
Form with any invalid field | Show a hint near the button | Not a replacement for an error summary |
fieldset:has(:user-invalid) legend |
Group containing errors | Highlight the group heading | Radio groups especially |
Step-by-Step Implementation
1. Style errors only after interaction
/* Base: every field has the same border width in every state, so nothing shifts. */
.field input,
.field select,
.field textarea {
border: 2px solid var(--field-border, #94a3b8);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
}
/* Error state appears only after the user interacted or tried to submit. */
.field :is(input, select, textarea):user-invalid {
border-color: var(--error-border, #b91c1c);
background-image: url("data:image/svg+xml,…"); /* error icon: decoration, not information */
background-position: right 0.6rem center;
background-repeat: no-repeat;
padding-inline-end: 2.2rem;
}
/* The wrapper knows its field is invalid, so labels and hints can react too. */
.field:has(:user-invalid) label { color: var(--error-text, #b91c1c); }
.field:has(:user-invalid) .hint { display: none; } /* the error message replaces the hint */
:user-invalid also matches after a submit attempt, even on fields the user never touched, which is exactly the behaviour you want when reportValidity() runs. Keeping the border width identical in every state avoids the one-pixel “jump” that a thicker error border causes, the same principle as reserving space for error messages. The details of the pseudo-class are in styling invalid inputs with :user-invalid.
2. Let the error message carry meaning, not the colour
<div class="field">
<label for="email">Email address</label>
<p id="email-hint" class="hint">We'll send your receipt here.</p>
<input id="email" name="email" type="email" required aria-describedby="email-hint email-err">
<p id="email-err" class="field-error" hidden></p>
</div>
// Script owns the text; CSS owns the look.
form.addEventListener("focusout", (e) => {
const el = e.target as HTMLInputElement;
const out = document.getElementById(`${el.id}-err`);
if (!out) return;
out.textContent = el.validity.valid ? "" : el.validationMessage;
out.hidden = el.validity.valid;
});
WCAG 1.4.1 forbids colour as the only means of conveying information, so the red border is an enhancement of the message, never a substitute. The icon in the background image is decoration for the same reason; the text is the information.
3. Mark required and optional fields with text
/* Append the word, not just an asterisk, to labels of required fields. */
.field:has(:required) label::after {
content: " (required)";
font-weight: 400;
color: var(--muted-text, #475569);
}
Generated content is read by most screen readers, but not all, and it cannot be translated by page-translation tools reliably. For multilingual products put the word in the markup and use CSS only to style it. The trade-offs are discussed in styling required field indicators.
4. Group-level styling with :has()
fieldset:has(input[type="radio"]:user-invalid) {
border-color: var(--error-border, #b91c1c);
}
fieldset:has(input[type="radio"]:user-invalid) legend::after {
content: " — choose one option";
color: var(--error-text, #b91c1c);
}
Radio groups are the classic case: the error belongs to the group, but validity lives on the individual radios. :has() lets the fieldset react without script. Patterns for groups, labels and whole forms are collected in styling validation states with the :has() selector.
State Management and Edge Cases
CSS state selectors reflect the browser’s view of validity, which includes your custom rules as long as you use setCustomValidity(). That makes them robust — but there are timing subtleties.
- Custom errors count. A field with a custom validity message matches
:user-invalidonce interacted, so server errors applied withsetCustomValidityare styled automatically. - Interaction definition. Browsers treat a field as “user-interacted” after it is changed and blurred, or after a submit attempt. A field focused and left without changes may not count, which is usually what you want.
- Reset.
form.reset()returns fields to the non-interacted state, so:user-invalidstops matching — no class cleanup needed. - JavaScript-set values. Values set from script do not count as interaction; the field will not show
:user-invaliduntil the user touches it or submits. - Disabled and read-only fields. Both are barred from constraint validation, so neither ever matches
:user-invalid— convenient for conditional sections that are disabled until needed.
Accessibility Compliance for Visual States
CSS styling touches several success criteria at once. 1.4.1 Use of Color: every error needs text, and required fields need a textual indicator. 1.4.11 Non-text Contrast: the error border must have 3:1 contrast against its surroundings, which pale reds often fail on white — #b91c1c on white passes; #fca5a5 does not. 1.4.3 Contrast applies to the error text itself (4.5:1). And in forced-colours mode (Windows High Contrast), authored colours are replaced with system colours, so an error communicated only by border-color disappears. Use a style change that survives colour replacement — a thicker border, an outline style, an icon drawn with currentColor — inside a forced-colors block.
@media (forced-colors: active) {
.field :is(input, select, textarea):user-invalid {
border-style: dashed; /* shape survives when colours are replaced */
outline: 2px solid CanvasText;
outline-offset: 2px;
}
}
The forced-colours case is covered in depth in error states in forced colors mode.
Common Gotchas and Debugging
Styling :invalid. Every empty required field is red on page load.
/* Before */
input:invalid { border-color: red; }
/* After */
input:user-invalid { border-color: var(--error-border, #b91c1c); }
Styling from aria-invalid and pseudo-classes inconsistently. If script sets aria-invalid at different moments than :user-invalid matches, the page shows two different notions of “invalid”. Pick one source for visuals — the pseudo-class — and keep aria-invalid in step with the visible message.
:has() performance fears. :has() is fast in all current engines for patterns like .field:has(:user-invalid). Avoid very broad forms such as body:has(input:user-invalid) on huge pages, which force wide invalidation on every input.
Placeholder-as-label styling. Styling :placeholder-shown to hide labels creates fields with no visible label once typing starts. Keep real labels.
Progressive Fallback for Older Engines
Older browsers without :user-invalid need a fallback that still avoids the angry form. The simplest is an attribute your script sets on the form after the first submit attempt — data-submitted — combined with a class or aria-invalid set on blur. Wrap the modern selectors in @supports selector(:user-invalid) so the two approaches never fight.
/* Fallback: script sets aria-invalid on blur/submit */
.field [aria-invalid="true"] { border-color: var(--error-border, #b91c1c); }
@supports selector(:user-invalid) {
.field [aria-invalid="true"] { border-color: var(--field-border, #94a3b8); } /* neutralise fallback */
.field :is(input, select, textarea):user-invalid { border-color: var(--error-border, #b91c1c); }
}
When to Show Success With :user-valid
Success styling is tempting to apply everywhere, and doing so dilutes it. A green tick on a first-name field tells the user nothing they did not know; a green tick on a password that meets every rule, a username confirmed available, or a card number that passes its checksum is genuinely reassuring. Reserve :user-valid styling for fields where validity is not obvious from the value itself, and keep it quieter than error styling — a subtle icon rather than a full green border — so errors stay visually dominant. Never style :valid on load: an optional empty field is technically valid, and a page full of ticks before anyone has typed anything is as confusing as a page full of red. The broader guidance on positive feedback is in showing valid field checkmarks accessibly.
Animating State Changes Responsibly
A small transition when a field enters its error state — the border colour fading in over 150 milliseconds — helps users notice the change. Large motion does the opposite for many people: shaking fields and bouncing icons can trigger vestibular discomfort and distract from the message. Keep validation transitions to colour and opacity, limit them to a fraction of a second, and remove them entirely under prefers-reduced-motion: reduce. If a design insists on a shake, it must be subtle, one-off, and disabled for reduced motion, as covered in accessible error shake animation with reduced motion.
.field :is(input, select, textarea) { transition: border-color 150ms ease, box-shadow 150ms ease; }
@media (prefers-reduced-motion: reduce) {
.field :is(input, select, textarea) { transition: none; }
}
Styling Custom Controls and Framework Components
The same selectors work for components as long as those components participate in native validation. A form-associated custom element exposes its validity through ElementInternals, so rating-input:user-invalid matches exactly like an input, as described in form-associated custom elements. Framework components that render native inputs inherit the behaviour for free, provided they do not replace native validity with a separate “error” prop that CSS cannot see. If a component library only exposes errors through props, bridge them into the DOM by calling setCustomValidity() on the underlying input — then :user-invalid and :has() keep working and the styling layer stays framework-independent.
Focus Styles Must Win Over Error Styles
An invalid field that receives focus is in two states at once, and the focus indicator must stay clearly visible — WCAG 2.4.7 requires a visible focus indicator, and 2.4.11 in WCAG 2.2 asks that it not be obscured. A common failure is an error style that sets outline: none or a red box-shadow which replaces the focus ring, so keyboard users land on the erroring field (because reportValidity() moved them there) and cannot see where they are. Order your rules so :focus-visible comes after the error rules with equal or higher specificity, and use a focus colour that contrasts with both the neutral and the error border. A double ring — a white inner outline plus a dark outer one — survives on any background colour and in both themes.
.field :is(input, select, textarea):user-invalid { border-color: var(--error-border); }
.field :is(input, select, textarea):focus-visible {
outline: 3px solid var(--focus-ring, #1d4ed8);
outline-offset: 2px;
box-shadow: 0 0 0 2px #ffffff; /* inner halo keeps the ring visible on red borders */
}
Printing and Read-Only Views
Validation styling has no place on a printed receipt or a read-only review screen, yet both often reuse form markup. A @media print block that resets error colours and hides hints, plus a read-only mode that removes interaction-based styling (read-only inputs are barred from constraint validation, so :user-invalid will not match them anyway), keeps confirmation pages calm and legible.
Theming Validation States With Custom Properties
Design systems benefit from routing every validation colour through custom properties — --error-border, --error-text, --success-border, --field-border — defined once for light and dark themes. The selectors stay the same across themes and products, and a dark theme only swaps the variables. Validate each pair for contrast in both themes: an error red that passes on white often fails on a dark surface, where a lighter red is needed for 3:1 non-text contrast and 4.5:1 text contrast. Keep success colours visually distinct from focus colours too; a green focus ring on a green “valid” border hides the focus indicator.
:root { --error-border: #b91c1c; --error-text: #b91c1c; --field-border: #94a3b8; }
:root[data-theme="dark"] { --error-border: #f87171; --error-text: #fca5a5; --field-border: #64748b; }
Testing Visual Validation States
Visual states are easy to regress with an innocent CSS change, so give them tests. Playwright can assert computed styles after interaction — for example that an untouched required field has the neutral border and the same field after a submit attempt has the error border — and a visual comparison screenshot of the error state catches layout jumps. Run the screenshot test in light, dark and forced-colours emulation (page.emulateMedia({ forcedColors: "active" })), which catches the “error disappears in High Contrast” bug that manual testing almost never finds. The general approach to browser tests is in testing form error messages with Playwright.
import { test, expect } from "@playwright/test";
test("errors are not styled before interaction", async ({ page }) => {
await page.goto("/signup");
const email = page.getByLabel("Email address");
const border = () => email.evaluate((el) => getComputedStyle(el).borderColor);
const pristine = await border();
await page.getByRole("button", { name: "Create account" }).click();
expect(await border()).not.toBe(pristine); // now :user-invalid
await page.emulateMedia({ forcedColors: "active" });
await expect(email).toHaveCSS("border-style", "dashed"); // shape survives forced colours
});
Browser Compatibility Matrix
| Feature | Chromium | Firefox | Safari | Fallback |
|---|---|---|---|---|
:user-invalid / :user-valid |
119+ | 88+ | 16.5+ | aria-invalid set by script |
:has() |
105+ | 121+ | 15.4+ | Classes toggled on wrappers |
@supports selector() |
83+ | 69+ | 14.1+ | — |
forced-colors |
89+ | 89+ | 16+ | — |
:required / :optional |
All | All | All | — |
Frequently Asked Questions
What is the difference between :invalid and :user-invalid?
:invalid matches any field that currently fails its constraints, including empty required fields on page load. :user-invalid only matches after the user has interacted with the field or tried to submit, which is almost always what a design wants.
Can CSS show validation error messages?
CSS can style fields, labels and groups based on validity, but the error text should come from the DOM — a message element filled from validationMessage and linked with aria-describedby — so it is announced and translatable.
How do I style a label when its input is invalid?
Wrap the label and input in a container and use .field:has(:user-invalid) label, or use label:has(+ input:user-invalid) when the label directly precedes the input.
Do validation colours need to meet contrast requirements?
Yes. Error text needs 4.5:1 contrast and the error border needs 3:1 against adjacent colours. Colour must also never be the only indicator, and styles should survive forced-colours mode.
Related Guides
- Styling Validation States with the :has() Selector — groups, labels and forms.
- Styling Required Field Indicators — marking required and optional fields.
- Styling Invalid Inputs with :user-invalid — the pseudo-class in depth.
- Error States in Forced Colors Mode — making errors survive High Contrast.
← Back to Mastering HTML5 Native Form Validation