Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

CSS Input Validation

CSS input validation driven by the browser's valid, invalid and user-invalid pseudo classes. User-invalid waits until the field has been touched and left.

Level
Intermediate
Published
Updated
Reading time
8 min

Try it

3 live demos

Enter a valid email address
At least 3 characters required

Type in a field and tab away

Other ways to build it

Submit button grayed out until the form validates

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

Telling an out of range value apart from an empty one

See the code

Build it

6 snippets to copy

CSS Input Validation

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 check mark 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; }

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.

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;
}

Understand it

How it works and 6 properties

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.

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 recolored per state without a second file. Swapping the icon for a content string would work too, but coloring a mask is what keeps the check mark 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 only going red, which is the difference between a form that corrects someone and one that only stops them.

Accessibility notes

Color is not a message. A red border tells a sighted reader something is wrong and tells someone using assistive technology 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.

The check mark and cross here are background on a ::after with a mask, which means they are purely decorative. 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 blocks the mouse but not the keyboard, the button keeps its enabled state in the accessibility tree, 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.

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 anyone who cannot see or distinguish the colors 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.

Ship it

6 features in 4 browsers, 6 questions

Browser support

Feature Chrome Firefox Safari Edge
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. :user-valid and :user-invalid are not in the data set this table references, 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.

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.

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, CSS validation does not replace server side validation. 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 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. Both demo forms use method="dialog" so pressing the submit button does not reload the page. A form with that method outside a dialog submits nowhere after the browser's own validation runs.