Styling Invalid Inputs with the :user-invalid Pseudo-Class

The :invalid pseudo-class paints a field red the instant the page loads, long before anyone has typed a character; :user-invalid solves that by deferring the red border until the user has actually interacted with the control and the Constraint Validation API has judged the value wrong.

When to Use This :user-invalid Pseudo-Class Approach

Reach for :user-invalid when you want purely presentational error styling that tracks real user intent, without writing any JavaScript to toggle classes. The browser decides “has this person touched the field and left it invalid?” for you.

  • Use :user-invalid when the styling should appear only after interaction (blur or a submit attempt), which is the behaviour users expect.
  • Use :invalid only for fields that are invalid by design at load and where an immediate cue is acceptable (rare).
  • If you need to render a text message next to the field, not just a border, pair this with accessible error messaging rather than CSS alone.
  • If validity depends on another control, CSS pseudo-classes cannot express it; see cross-field validation and drive the state from script.

For the difference between merely reading a field’s state and surfacing native bubbles, see checkValidity vs reportValidity.

:invalid versus :user-invalid Two CSS pseudo-classes compared by when they begin matching an invalid field. :invalid - matches on page load - fires before any input - ignores interaction state best when: field invalid by design :user-invalid - matches after interaction - waits for blur or submit - tracks real user intent best when: styling after a touch
Prefer :user-invalid so the error style appears only once the user has engaged the field.

Baseline Markup and CSS with novalidate and :user-invalid

The site baseline ships every form with novalidate and drives validity through a single manual reportValidity() call. :user-invalid layers on top of that: it styles the border while your one API call handles the submit gate and native messages.

<form id="signup" novalidate>
  <label for="email">Work email</label>
  <input id="email" name="email" type="email" required autocomplete="email" />
  <button type="submit">Create account</button>
</form>
/* Border stays neutral until the user interacts and leaves it invalid. */
input {
  border: 1px solid #cbd5e1;
}
input:user-invalid {
  border-color: #ef4444;
  outline-color: #ef4444;
}
/* Optional: reward a corrected field. */
input:user-valid {
  border-color: #10b981;
}
// novalidate + a single reportValidity() call is the canonical baseline.
// :user-invalid handles the *visual* state; this handles the *submit gate*.
const form = document.querySelector<HTMLFormElement>("#signup")!;

form.addEventListener("submit", (event: SubmitEvent): void => {
  // reportValidity() returns false and shows native bubbles on the first
  // invalid control. It also flips :user-invalid to match immediately,
  // even for fields the user never focused.
  if (!form.reportValidity()) {
    event.preventDefault();
    return;
  }

  event.preventDefault(); // hand off to fetch / your submit pipeline
  // ...submit the form data here
});

Because reportValidity() counts as an interaction, a submit attempt promotes every still-invalid field into :user-invalid at once, giving you a coordinated red pass without any manual class toggling.

:user-invalid: State and Selector Reference

Option Type Default Purpose
:user-invalid CSS pseudo-class not matching Matches an interacted control whose value fails a constraint.
:user-valid CSS pseudo-class not matching Matches an interacted control that now passes all constraints.
:invalid CSS pseudo-class may match at load Matches any invalid control regardless of interaction.
novalidate form attribute absent Suppresses automatic native popups; you call reportValidity() yourself.
required input attribute absent Empty value makes the control invalid, so :user-invalid can match.
reportValidity() method n/a Validates, shows bubbles, and counts as an interaction for :user-invalid.

Verification Steps

import { test, expect } from "@playwright/test";

test(":user-invalid appears only after blur", async ({ page }) => {
  await page.goto("/signup");
  const email = page.locator("#email");

  // No red border before interaction.
  await expect(email).not.toHaveCSS("border-color", "rgb(239, 68, 68)");

  // Focus then blur an empty required field.
  await email.focus();
  await email.blur();

  await expect(email).toHaveCSS("border-color", "rgb(239, 68, 68)");
});

Edge Cases & Failure Modes

Programmatic value changes do not trigger interaction. Setting input.value = "..." from script updates validity but does not mark the control as interacted, so :user-invalid will not match. If you populate a field yourself and need the error style, drive it with a class or call reportValidity() so the interaction flag flips.

setCustomValidity() participates, but the message needs a home. A control with a non-empty custom message counts as invalid, so :user-invalid matches it. The string itself only surfaces through native bubbles or your own markup; see custom validity messages and How to Use setCustomValidity Correctly for wiring it up.

Async validity is invisible to CSS. A value that only fails after an asynchronous server check or a Zod schema validation pass is valid as far as the Constraint Validation API is concerned, so :user-invalid will never match it. Reflect the server verdict with setCustomValidity() or a state class, otherwise the border stays green while the field is actually rejected.

The “User-Interacted” Flag That Powers the Match

:user-invalid is not a separate validity check; it is :invalid gated behind a per-control boolean the HTML specification calls the user-interacted flag. Every listed form element starts with that flag false. The browser flips it to true on the first change that originates from the user — a keystroke, a paste, a selection in a native picker, or a submit attempt that reaches the control. Once set, the flag never resets for the life of that element, which is why a field the user fixed and then re-emptied still qualifies for :user-invalid: it has been interacted with, and it is currently invalid.

This design has a subtle consequence. The flag is bound to the element, not to the current value, so replacing the node (for example, re-rendering a component that swaps the <input> for a fresh one) discards the interaction history and the field reverts to a pristine, unmatched state. Frameworks that key inputs stably preserve the flag across renders; those that remount inputs on every keystroke will appear to “lose” the red border. If you rely on :user-invalid inside a component tree, keep the input’s identity stable so the browser’s interaction bookkeeping survives.

Scoping the Error Style to Its Field Group with :has()

A red border alone rarely communicates enough. The common refinement is to tint the entire field group — label, control, and helper text — but only once the control itself qualifies. Modern selectors let you express that without a single line of script by combining :has() with :user-invalid:

/* Style the wrapper, but only when its control is user-invalid. */
.field:has(:user-invalid) {
  --field-accent: #ef4444;
}
.field:has(:user-invalid) .field-hint {
  color: var(--field-accent);
}
.field:has(:user-valid) {
  --field-accent: #10b981;
}

Because :has() is evaluated on the same schedule as any other selector, the wrapper recolors on exactly the same blur or submit event that promotes the control, so the group and the border always stay in sync. Guard the enhancement for older engines with @supports selector(:has(*)) and let the plain :user-invalid border be the fallback.

Reflecting Async Verdicts into the Same Selector

The edge-case note above warned that server-side rejections are invisible to CSS. The clean fix is to route the async verdict back through the Constraint Validation API so :user-invalid — and every rule scoped to it — lights up for free, with no parallel styling path to maintain:

// Push a server verdict into native validity so :user-invalid can match it.
async function applyRemoteVerdict(input: HTMLInputElement): Promise<void> {
  const res = await fetch(`/api/email-available?value=${encodeURIComponent(input.value)}`);
  const { available } = (await res.json()) as { available: boolean };

  // A non-empty custom message makes the control invalid; "" clears it.
  input.setCustomValidity(available ? "" : "That email is already registered.");

  // setCustomValidity alone does not set the user-interacted flag, so a
  // never-focused field would style silently. reportValidity() flips the
  // flag AND surfaces the message, keeping the visual and semantic state aligned.
  input.reportValidity();
}

The key move is that you never write a .is-invalid class. The server answer becomes a native constraint, so the CSS you already wrote for :user-invalid and its :has() wrapper applies unchanged. When the next keystroke changes the value, clear the custom message at the top of your input handler so a stale verdict cannot outlive the value that produced it.

Accessibility: Colour Is Never the Whole Signal

:user-invalid changes appearance only; it exposes nothing to assistive technology. A screen-reader user hears no difference between a green and a red border, and a low-vision user may not perceive the hue change at all. Treat the pseudo-class as the visual layer of a larger contract: pair every red border with a text message referenced by aria-describedby, ensure the control carries aria-invalid="true" when it fails, and never encode the error using colour as the sole channel — add an icon, a message, or a border-width change so the state survives a grayscale rendering. The managing focus after validation failure guide covers moving the caret to the first offending field so keyboard users are not left hunting for the red one.

Frequently Asked Questions

Does :user-invalid work without JavaScript?

Yes. The pseudo-class is evaluated entirely by the browser from native constraints like required, type, and pattern, so the border styling works with CSS alone. You still want the novalidate plus reportValidity() baseline to control the submit gate and message display, but the visual state does not depend on it.

What is the difference between :user-invalid and :invalid?

:invalid matches an invalid control at all times, including on first paint, which floods a fresh form with red. :user-invalid only matches after the user has interacted with the control or attempted to submit, matching the timing users actually expect. In almost every real form, :user-invalid is the one you want.

How do I show a text error, not just a red border?

CSS pseudo-classes style the control but cannot inject message text. Read the field's validationMessage or your own copy and render it into an element wired with aria-describedby, then move focus to the first failure. See the guides on inline error messaging and focus management for the full accessible pattern.

← Back to Constraint Validation API Deep Dive