CSS Input Validation

Real-time form validation feedback using :valid, :invalid, and :user-invalid.

Published May 19, 2026 Intermediate 8 min read

CSS input validation styles a form from the browser's own constraint validation, with no script deciding what counts as valid. An <input type="email"> that does not hold an email address matches :invalid. Add required, minlength or pattern and each one feeds the same set of pseudo-classes. The stylesheet reads the result and paints the field.

The pseudo-class that makes this usable in practice is :user-invalid. Plain :invalid matches an empty required field from the moment the page loads, so a blank form arrives covered in red before anybody has typed a character. :user-invalid waits until the field has been interacted with and left, which is when a person expects to be told they got something wrong. Two more versions follow: a submit button that stays grayed out until the whole form validates, and a number field that distinguishes an empty value from one outside the allowed range.

Type in a field and tab away

Enter a valid email address
At least 3 characters required

HTML

<form>
  <div class="field">
    <input type="email" placeholder="Email address" required>
    <span class="field-icon"></span>
    <div class="hint">Enter a valid email address</div>
  </div>
  <div class="field">
    <input type="text" placeholder="Username (min 3 chars)" minlength="3" required>
    <span class="field-icon"></span>
    <div class="hint">At least 3 characters required</div>
  </div>
</form>

CSS

.field {
  position: relative;
  margin: .75rem 0;
}

.field input {
  display: block;
  width: 260px;
  /* right padding leaves room for the icon */
  padding: .65rem 2.5rem .65rem .9rem;
  border: 2px solid #2a2a2d;
  border-radius: 8px;
  background: #141415;
  color: #f0f0f0;
  font-family: inherit;
  font-size: .95rem;
  outline: none;
  transition: border-color .2s;
}

.field input:focus { border-color: #b8ff57; }

/* :user-invalid only fires once the field has been touched */
.field input:user-invalid { border-color: #ff6b6b; }
.field input:user-valid   { border-color: #57d9a3; }

.field-icon {
  position: absolute;
  right: .75rem;
  top: 50%;
  transform: translateY(-50%);
  font-size: 1rem;
  pointer-events: none;
}

/* The tick and cross are masked boxes, so background sets their color */
input:user-invalid ~ .field-icon::after,
input:user-valid ~ .field-icon::after {
  content: "";
  display: block;
  width: 16px;
  height: 16px;
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
}

input:user-invalid ~ .field-icon::after {
  background: #ff6b6b;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='15' y1='9' x2='9' y2='15'/%3E%3Cline x1='9' y1='9' x2='15' y2='15'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='15' y1='9' x2='9' y2='15'/%3E%3Cline x1='9' y1='9' x2='15' y2='15'/%3E%3C/svg%3E");
}

input:user-valid ~ .field-icon::after {
  background: #57d9a3;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='9 12 11 14 15 10'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='9 12 11 14 15 10'/%3E%3C/svg%3E");
}

.hint {
  margin-top: .25rem;
  font-size: .75rem;
  color: #88888f;
}

input:user-invalid ~ .hint { color: #ff6b6b; }

Other ways to build it

Submit button grayed out until the form validates

form:has(:invalid) matches the whole form while any control inside it fails, which lifts validation from the field to the form. Plain :invalid is the right choice here rather than :user-invalid, because the button should be grayed from the start, not only after somebody has visited every field. This is a visible explanation, not the enforcement: a stylesheet cannot set the disabled attribute, and the browser was already going to refuse an invalid submission. Fill both fields correctly and the button lights up.

HTML

<form class="gate-form">
  <div class="field">
    <input type="email" placeholder="Email address" required>
    <span class="field-icon"></span>
  </div>
  <div class="field">
    <input type="password" minlength="8" required
           placeholder="Password, 8+ characters">
    <span class="field-icon"></span>
  </div>
  <button class="gate-submit" type="submit">Create account</button>
</form>

CSS

.gate-submit {
  width: 100%;
  margin-top: .5rem;
  padding: .6rem 1rem;
  border: none;
  border-radius: 8px;
  background: #b8ff57;
  color: #0c0c0d;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background .2s, color .2s;
}

/* :invalid, not :user-invalid: gray from the start, not only
   after every field has been visited */
.gate-form:has(:invalid) .gate-submit {
  background: #2a2a2d;
  color: #88888f;
  cursor: not-allowed;
}

.gate-submit:focus-visible {
  outline: 2px solid #b8ff57;
  outline-offset: 3px;
}

Telling an out of range value apart from an empty one

:out-of-range matches a number that is present but outside the min and max bounds, which is a different problem from a field left blank and deserves a different message. Both hints are written into the markup and the stylesheet decides which one is shown, so no script picks the wording. Note that neither :in-range nor :out-of-range matches at all unless the input carries min or max. This field starts at 12 against a maximum of 10.

Between 1 and 10 per order
More than we hold in stock

HTML

<div class="field field-range">
  <label for="qty">Quantity</label>
  <input type="number" id="qty" min="1" max="10" value="12" required>
  <div class="hint hint-default">Between 1 and 10 per order</div>
  <div class="hint hint-over">More than we hold in stock</div>
</div>

CSS

.hint-over {
  display: none;
}

.field-range input:out-of-range {
  border-color: #ff9040;
}

.field-range input:out-of-range ~ .hint-default {
  display: none;
}

.field-range input:out-of-range ~ .hint-over {
  display: block;
  color: #ff9040;
}

.field-range input:in-range ~ .hint-default {
  color: #57d9a3;
}

How it works

:user-invalid is the key. Unlike :invalid, it only applies after the user has interacted with the field, which prevents red borders on untouched empty required inputs. :user-valid triggers on a valid value after interaction. Sibling combinators let the hint text and icon react to the <input>'s state.

Validity comes from the HTML, not the CSS. type="email" brings a built in pattern, required means not empty, minlength sets a floor on length, min and max bound a number, and pattern takes a regular expression for anything else. Every one of those feeds :valid and :invalid, and the browser also blocks submission of a form holding an invalid control, so the styling and the enforcement stay in agreement without being wired together.

:user-invalid and :user-valid add a condition the older pseudo-classes lack: the control has to have been edited, or the form submitted, before either can match. That difference is the whole reason this pattern is worth using now. With :invalid alone the usual workaround was :not(:placeholder-shown):invalid, which approximates it by waiting until something has been typed, and still lights up mid word while somebody is halfway through an address.

The icon and the hint are siblings of the input, selected with ~. That combinator only looks forward, so both have to be written after the input inside the field wrapper. The icon is a masked box rather than an image or a character: mask-image takes an inline SVG data URI, and background supplies the color, so one shape can be recoloured per state without a second file. Swapping the icon for a content string would work too, but coloring a mask is what keeps the tick and the cross visually identical in weight.

:has() moves this from field level to form level. form:has(:invalid) matches the whole form while any control inside it fails, which is enough to gray out a submit button or show a summary line. The browser was already going to refuse the submission, so the CSS is adding a visible reason rather than the enforcement itself. The same relational selector powers the label behavior on floating labels and step tracking on a multi step form.

:in-range and :out-of-range are the pair most people forget. They apply only to inputs that carry min or max, and they separate a value that is present but too large from one that is missing entirely. That distinction lets a quantity field say what is wrong rather than just going red, which is the difference between a form that corrects someone and one that only stops them.

CSS properties used

:user-invalid
Matches a control that fails validation, but only after it has been edited and left or the form has been submitted.
:user-valid
The same timing rule applied to a control that passes. Useful for a confirming tick rather than silence.
:invalid
Matches from page load, including empty required fields. Right for form level checks, wrong for per field coloring.
:in-range / :out-of-range
Apply to inputs with min or max. Separate an out of bounds number from an empty one.
mask-image
Draws the tick and cross from one inline SVG shape, with background supplying the color for each state.
:has()
Lifts validity to the form. form:has(:invalid) styles the whole form while any control inside it fails.

Browser support

FeatureChromeFirefoxSafariEdge
form validation10410.112
pattern attribute10410.112
minlength attribute405110.117
:in-range / :out-of-range535010.179
mask-image1205315.4120
:has()10512115.4105

Figures come from Can I Use. There is no Can I Use entry for :user-valid and :user-invalid, so no version numbers are quoted for them here: all four major browsers support them now, Firefox having shipped the same behavior first under the prefixed name :-moz-ui-invalid, with Chrome and Safari following considerably later. Check the current position on MDN before making them the only signal on a form. The unprefixed mask-image figures above are why the stylesheet also carries -webkit-mask-image, which older WebKit and Blink accept.

Accessibility notes

Color is not a message. A red border tells a sighted reader something is wrong and tells a screen reader user nothing at all, because CSS state does not reach the accessibility tree. The hint text under each field is what carries the meaning, and it has to be associated with the input by aria-describedby on a real form so it is announced when focus arrives.

The tick and cross here are background on a ::after with a mask, which means they are purely decorative and are never announced. That is the correct outcome, since the hint text already says what the icon is showing. It would be wrong to rely on the icon alone.

:user-invalid timing is an accessibility feature as much as a visual one. Marking every empty required field as an error on page load produces a screen full of errors before the reader has done anything, and it trains people to ignore the styling. Waiting until a field has been left gives the error a cause the reader can connect it to.

The base rule sets outline: none on the input and relies on a border color change for focus. A two pixel accent border is visible, but it sits in the same place as the validity colors, so a focused invalid field has to choose between showing focus and showing the error. Adding a separate :focus-visible outline with an offset avoids the collision, and this page's stylesheet does that.

A submit button grayed out by CSS is still an enabled button. pointer-events: none hides it from a mouse but not from a keyboard or a screen reader, and a real disabled attribute cannot be set from a stylesheet. Treat the graying as a hint, and let the browser's own refusal to submit an invalid form do the actual blocking.

What you can build with it

  • Sign up forms. Email format and password length checked as the reader moves between fields, with no request to the server.
  • Address and postcode fields. A pattern per country, giving immediate feedback on a format that is easy to mistype.
  • Quantity and date pickers. min and max with :out-of-range, so an impossible value gets a specific message rather than a generic one.
  • Submit gating on long forms. form:has(:invalid) grays the button until every field passes, which stops a reader hunting for what they missed.
  • Inline search filters. A pattern on a query field marks a malformed filter before it is sent anywhere.

Mistakes worth avoiding

  • Using :invalid for per field coloring. Every empty required field turns red on page load, which is the single most common complaint about CSS form validation.
  • Writing the hint or the icon before the input. The ~ combinator only looks forward, so neither reacts and the field appears to validate silently.
  • Relying on color alone. A red border with no text leaves a screen reader user, and anyone with a color vision difference, with no idea what is wrong.
  • Treating CSS validation as security. It is a convenience for the person filling the form and nothing more, since anything sent to a server has to be checked again there.
  • Forgetting that :out-of-range needs min or max on the input. Without either attribute it never matches, and a number field silently falls back to only being empty or filled.

Frequently asked questions

What is the difference between :invalid and :user-invalid?
:invalid matches as soon as the page loads, so an empty required field is already an error before anybody has touched it. :user-invalid waits until the field has been edited and left, or the form submitted. For coloring individual fields you almost always want :user-invalid. For a form level check such as graying out a submit button, :invalid is the right one.
Can CSS validation replace server side validation?
No. Constraint validation runs in the browser and can be bypassed by anyone who wants to, so it is a convenience for the person filling the form. Everything still has to be checked again on the server.
How do I style a form field that is empty but not yet touched?
:placeholder-shown matches while the field is empty, provided it has a placeholder. Combining it as :not(:placeholder-shown):invalid was the standard workaround before :user-invalid existed, and it still works as a fallback in browsers that do not support the newer pseudo-class.
Can I disable a submit button with CSS until the form is valid?
You can style it as unavailable with form:has(:invalid) button, but you cannot actually disable it, because disabled is an HTML attribute and no stylesheet can set one. The browser refuses to submit an invalid form regardless, so the graying is a visible explanation rather than the mechanism.
Why does my email field show as valid when it clearly is not?
The built in email check is deliberately loose. It requires text, an at sign and more text, so a@b passes. Add a pattern if you need something stricter, and accept that no regular expression matches the real rules for email addresses.
Does input validation styling work without JavaScript?
Entirely. Every pseudo-class on this page is driven by the browser's own constraint validation, which reads the type, required, minlength, pattern, min and max attributes in the HTML. No script is involved at any point.