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-invalidwhen the styling should appear only after interaction (blur or a submit attempt), which is the behaviour users expect. - Use
:invalidonly 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.
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.
Related Guides
- Reading ValidityState Flags — inspect exactly why a field is invalid before you style or message it.
- checkValidity vs reportValidity — decide whether to show native bubbles or validate silently.
- Prevent Default Submission Without Losing Validation — keep native checks while taking over the submit.
- HTML5 Input Types & Attributes — the constraints that make
:user-invalidmatch in the first place. - Managing focus after validation failure — pair the red border with correct keyboard focus.