TechEarl

Styling Form Validation in CSS: :user-valid, :user-invalid, :required, :optional

Style form validation with pure CSS. :user-valid and :user-invalid only react after the user interacts, so empty required fields stop showing red on page load. Plus :required, :optional, :in-range, :placeholder-shown, :autofill, and :has().

Ishan Karunaratne⏱️ 8 min readUpdated
Share thisCopied
Style form validation in CSS with :user-valid and :user-invalid so required fields do not turn red until the user interacts, plus :required, :optional, :in-range, :placeholder-shown, :autofill and :has().

The right way to style form validation in CSS in 2026 is :user-valid and :user-invalid, not :valid and :invalid. The difference is the whole reason this article exists: :invalid matches an empty required field the instant the page loads, so a pristine form lights up red before the user has typed a single character. :user-invalid only matches after the user has actually interacted with the field, which is the behavior you wanted all along.

css
/* The old, annoying way: red on page load */
input:invalid {
  border-color: #d33;
}

/* The modern way: red only after the user has touched it */
input:user-invalid {
  border-color: #d33;
}

input:user-valid {
  border-color: #2a9d4a;
}

That one swap fixes the most common complaint about CSS form validation. Everything below is the rest of the toolkit: the required/optional split, range constraints on numbers and dates, the placeholder and autofill states, and using :has() to color the whole field row instead of just the input.

Why :user-invalid exists

:valid and :invalid reflect the constraint-validation state at all times. The moment the browser parses <input required>, that field is invalid (it is empty, and empty fails required), so input:invalid styles it red immediately. Users read a red border as "I did something wrong," and they have not done anything yet.

People worked around this for years with JavaScript: add a was-focused or touched class on blur, then scope the invalid styles to that class. :user-invalid is the browser doing exactly that bookkeeping for you. It matches only once the field has been interacted with (edited, then blurred, or the form submitted) and is still invalid. :user-valid is the mirror image: interacted with, and now valid.

css
.field input {
  border: 2px solid #ccc;
}

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

.field input:user-valid {
  border-color: #2a9d4a;
}

No .touched class, no blur listener, no JavaScript. A required email field sits neutral on load, stays neutral while the user types, turns green when they enter a valid address, and turns red only if they leave it bad.

Baseline and support

:user-valid and :user-invalid reached Baseline "Newly available" in October 2023, when the last of the core engines shipped them: Chrome and Edge 119, Safari 16.5, and Firefox (which was the early implementer, back at version 88). Thirty months later they crossed into Baseline "Widely available" (April 2026), so as of June 2026 they are not just interoperable but safe-by-default across the entire supported browser range. For ordinary public sites you can use them directly. If you still owe support to a pre-2023 browser, treat :user-invalid as the progressive enhancement and keep a :invalid-plus-.touched fallback for the long tail.

:required and :optional

These two split your fields by whether they carry the required attribute, which is the cleanest way to mark required fields visually without hard-coding a class on each one.

css
input:required {
  border-left: 3px solid #d33;
}

input:optional {
  border-left: 3px solid #ccc;
}

Anything that can be required (text inputs, selects, textareas) matches one or the other. I lean on :required for the little "you must fill this" affordance and let :user-invalid handle the after-the-fact error state, so the two jobs stay separate.

:in-range and :out-of-range

For inputs with min and max (number, range, and the date and time types), :in-range and :out-of-range reflect whether the current value sits inside the bounds. They are constraint states, not interaction states, so pair them with :user-invalid if you only want the warning to show after the user has typed.

css
input[type="number"]:out-of-range {
  background: #fff3f3;
  border-color: #d33;
}

input[type="number"]:in-range {
  border-color: #2a9d4a;
}

Given <input type="number" min="1" max="10">, typing 15 matches :out-of-range and 5 matches :in-range. This works for <input type="date" min="2026-01-01"> too, which is handy for "no dates in the past" pickers without writing a comparison in JavaScript.

:placeholder-shown

:placeholder-shown matches while the placeholder text is visible, which is to say while the field is empty. It is the engine behind the floating-label pattern: the label sits inside the empty field, then animates up to a caption position once the user starts typing.

css
.float-label label {
  transition: transform 0.15s ease;
}

/* Label is "inside" the field while the placeholder shows (field empty) */
.float-label input:placeholder-shown + label {
  transform: translateY(1.6rem);
}

The field needs a real placeholder attribute (even a single space) for this to match. Note the selector targets the input but styles the sibling label via the + combinator.

:autofill

:autofill matches a field the browser has filled from saved data. Browsers paint their own yellow or blue background on autofilled fields and have historically blocked you from overriding it with a plain background. The trick is a very long transition on the background color, which the autofill paint cannot fast-forward through.

css
input:autofill {
  /* Keep the browser's autofill background from showing through */
  transition: background-color 600000s 0s;
  -webkit-text-fill-color: #111;
}

WebKit and Blink expose this as :-webkit-autofill; the standard :autofill is the spec name, and listing both as a selector group is the safe move for older Safari and Chrome builds.

Style the whole row with :has()

The pseudo-classes above all match the input. To restyle the field's wrapper, label, or an error message based on the input's state, reach for :has(), the parent selector. This is where the modern form-styling story gets genuinely good: you can color an entire field group off one input's validity, with no JavaScript and no class plumbing.

css
/* Tint the whole field group red once the input is user-invalid */
.field:has(input:user-invalid) {
  background: #fff3f3;
}

.field:has(input:user-invalid) label {
  color: #d33;
}

/* Reveal an inline error message only when the field is invalid */
.field .error {
  display: none;
}

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

:has() has been Baseline since December 2023, so it pairs naturally with :user-invalid from the same era. For more on what the parent selector unlocks, I wrote up the patterns in the CSS :has() selector.

Caveats worth knowing

CSS validation styling reflects the browser's constraint validation, which means it only knows about HTML constraints: required, type="email", pattern, min, max, minlength, maxlength. A "passwords must match" or "username already taken" check is application logic the browser cannot see, so those still need JavaScript (set a custom validity message, or toggle a class) and the CSS hooks into that state.

And styling is not a substitute for an accessible error: a red border alone is invisible to a screen reader and to anyone who cannot distinguish the color. Keep a real text message, wire it up with aria-describedby, and let the :user-invalid styling be the visual layer on top of that, not the whole story.

FAQ

See also

  • The CSS :has() selector: the parent selector that lets you style a field's wrapper, label, or error message off the input's validity state.
  • CSS accent-color: recolor checkboxes, radios, and range sliders to match your form's theme in one line.
  • CSS :focus-within: style a field group while any element inside it has focus, the natural companion to validation states.

Sources

Authoritative references this article was fact-checked against.

TagsCSSform validationuser-invaliduser-validpseudo-classesrequiredhas selectoraccessibility

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Using AI with WP-CLI for Faster WordPress Operations

The WP-CLI patterns that compose well with AI assistants: multi-step plans with checkpoint approval, generated one-off scripts, database surgery, content migrations at scale, and what to never delegate.

Using AI to Update ACF Fields and WordPress Content

AI plus WP-CLI plus ACF is the canonical pattern for bulk content updates that used to take a careful afternoon. Schema-aware update_field calls, content rewrites at scale, image alt backfills, and the safety patterns that prevent disasters.