skip to content

On a freshly loaded page, an empty <input required> already matches the CSS :invalid pseudo-class before the user has typed anything. Why does that happen, and what does :user-invalid do differently?

level: seniorimportance: should knowfreq 40%

answer

  1. state, not history
  2. the form is red before anyone typed
  3. the missing condition is interaction
  4. a failed submit counts as interaction
  5. teams used to fake it with a class

basics

~20 s

:invalid reflects constraint state alone, so an untouched empty required field already matches it at page load. :user-invalid matches only after the user has edited the field or attempted submission, which is why error state should key off it.

solid answer

~50 s

`:invalid` is a pure function of the control's validity — it says nothing about interaction. An empty `required` field is invalid from the moment it is parsed, so it matches immediately and a form styled on `:invalid` greets the user with every field already flagged as an error, which trains people to ignore the signal. `:user-invalid` adds the missing condition: it matches only once the user has actually interacted with the control — edited it and moved on, or triggered a failed submission attempt — and it stops matching as soon as the value becomes valid. Its counterpart `:user-valid` behaves symmetrically. That pair became available across Chrome, Edge, Firefox and Safari during 2023; before then teams simulated it by having script add a class on `blur` or after a failed submit and keying the selector off that class instead.

go deeper

for a junior

Know that :invalid describes the control's validity right now, with no notion of whether the user has done anything, so an empty required field matches it immediately at load.

for a middle

Explain the extra condition :user-invalid carries — interaction or a failed submit — and that it clears itself as soon as the value becomes valid, along with its :user-valid counterpart.

for a senior

Show the production reasoning: error styling that is on before anything is wrong destroys the signal, and a marker class such as is-touched in an existing codebase is the fossil of the pre-2023 workaround.

for a principal

Own the product rule for when errors appear — silent until submit, eager afterwards, or on blur — and make sure the visual state and the programmatic invalid state are driven from one decision rather than drifting apart.

## Why :invalid matches too early Constraint validation is stateless with respect to the user. The moment the parser creates `<input required>` with no value, that control is invalid: `validity.valueMissing` is `true`, `checkValidity()` returns `false`, and it matches `:invalid`. Nothing about "has the user had a chance yet" enters into it. That is correct behaviour for a *validity* selector, and it is disastrous as an *error* selector. A sign-up form with six required fields, styled on `:invalid`, renders six red fields to a user who has done nothing wrong. The consequences are worse than ugly: the error styling carries no information (it is on before anything is wrong), users learn to tune it out, and by the time a field is genuinely wrong the signal has already been spent. Screen-reader users can hit a related problem when the same state drives an announced error or an `aria-invalid` attribute. Note also that `:invalid` is not limited to inputs — a `<form>` matches it while it contains an invalid control, and `<fieldset>` behaves the same way, so a naive `form:invalid` rule lights up on load for the same reason. ## What :user-invalid adds `:user-invalid` (and its mirror `:user-valid`) match only when both things are true: the control fails or passes its constraints, **and** the user has interacted with it. "Interacted" covers editing the value and then leaving the field, and it also covers a submission attempt — after a blocked submit, the offending fields match `:user-invalid` even if the user never focused them, which is exactly the behaviour you want from an error state. The state is also self-clearing. As soon as the value satisfies the constraints, the control stops matching `:user-invalid` and starts matching `:user-valid`. You get "show the error after they have tried, remove it the moment they fix it" with no script at all. ```css /* wrong: everything is red on load */ input:invalid { border-color: red; } /* right: only after the user has had a turn */ input:user-invalid { border-color: red; } ``` ## Availability and the older workaround The pair became available across Chrome, Edge, Firefox and Safari during 2023 (Firefox shipped it considerably earlier, and had long carried a vendor-prefixed forerunner). Before it was broadly usable, every design system reimplemented the same idea in script: on `blur`, or after a failed submit attempt, add a marker class such as `is-touched` to the control, and write the rule as `.is-touched:invalid`. Recognising that this class exists in a codebase *because* `:invalid` alone is unusable is a good sign in a candidate — it shows they know what the pseudo-class was standardised to replace. If a very old browser must be supported, the compatible move is progressive: keep the marker-class rule as the base, layer the `:user-invalid` rule on top, and let the newer engines take the simpler path. ## The timing question underneath The deeper point is that *when* to show an error is a product decision, and the two selectors encode different answers. Validating on every keystroke tells a user their email address is invalid while they are still typing the first three letters, which is noise. Validating on blur is the conventional compromise, and validating on submit is the minimum. `:user-invalid` implements roughly the blur-or-submit rule for free. When a product wants a different rule — say, be silent until submit, then eager afterwards — you are back to script-driven state, and the flags and events from the validation API give you the data to drive it. A related trap: because `:invalid` is live, a rule written on it can flicker mid-typing as the value crosses in and out of validity. `:user-invalid` reduces that too, because it only engages once the user has already left the field or submitted. ## What weak answers sound like "`:invalid` only matches after the user types" — no, that is the whole problem. "Just use `:invalid` with `:not(:focus)`" — that hides the error while the user is in the field but still flags every untouched field on load. "`:user-invalid` is the same as `:invalid` with a different name" — the interaction condition is the entire difference, and the reason the selector was added at all.

  • Does a control match :user-invalid if the user never focused it but submission failed?
    Yes. A blocked submission attempt counts as interaction, so every offending control enters the user-invalid state even if the user skipped past it. That is precisely the behaviour an error style wants: silent until the user has had a turn, then flagged on all the fields that actually blocked them.
  • How did teams get this behaviour before :user-invalid was broadly available?
    With a marker class. Script added something like `is-touched` on `blur` or to every control after a failed submit, and the stylesheet used `.is-touched:invalid` instead of bare `:invalid`. Finding that class in an old codebase is a direct fossil of the gap the pseudo-class was standardised to close.
  • Is there an equivalent problem with aria-invalid on an untouched field?
    Yes — the same timing mistake. Marking a control as invalid before the user has entered anything announces an error that has not happened yet. Whatever rule decides when the visual error appears should also decide when the field is programmatically marked invalid, so the two surfaces stay in step.

saying these in an interview costs you the question

  • :invalid only starts matching once the user types
  • :user-invalid is just an alias for :invalid
  • Adding :not(:focus) to :invalid solves the load-time problem
  • Only inputs can match :invalid, never the form
  • Error state should be shown on every keystroke

context