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.

Validation pseudo-classes at a glance A table of CSS pseudo-classes related to form validation, what each matches and whether it waits for user interaction. Matches when Waits for user :invalid / :valid constraint check fails / passes ✗ No :user-invalid / :user-valid same, after interaction or submit ✓ Yes :required / :optional required attribute present / absent n/a :placeholder-shown placeholder visible (field empty) n/a :has(:user-invalid) an ancestor contains such a field ✓ Yes
The :user-* pseudo-classes wait for interaction; the plain ones match from page load, which is why they are rarely what a design wants.

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.

Who does what in validation UI The native constraint model computes validity, script writes messages and wiring, and CSS pseudo-classes style fields, labels and groups from that validity. Constraint model validity flags on each field Script setCustomValidity, messages, reportValidity :user-invalid field border and icon :has() label, group, form reactions Forced colours system colours, no reliance on hue
Validity is computed once by the browser; script adds words, CSS adds visuals, and neither duplicates the other.

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-invalid once interacted, so server errors applied with setCustomValidity are 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-invalid stops matching — no class cleanup needed.
  • JavaScript-set values. Values set from script do not count as interaction; the field will not show :user-invalid until 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.
When :user-invalid starts and stops matching A required field starts pristine and unstyled, becomes interacted after an edit and blur or a submit attempt, and then matches :user-invalid while invalid and :user-valid while valid; reset returns it to pristine. pristine user-invalid user-valid blur or submit while invalid value fixed value broken form.reset() blur after valid edit
Validity can change at any time, but the styling only follows it after interaction, and reset clears the interacted flag.

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.

Fields that deserve a success state Two columns listing fields where a success indicator helps users and fields where it adds noise. Show success • password meeting every rule • username confirmed available • card number passing its checksum • postcode matched by lookup Skip success • names and free text • optional fields left empty • simple selects and checkboxes • anything valid before interaction
Success styling earns its place where validity is hard to judge by eye; elsewhere it is visual noise that weakens error states.

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.

← Back to Mastering HTML5 Native Form Validation

Explore This Section