Form Validation Layouts With :has(): Styling Groups From Field State

A field's validity lives on the <input>, but the things that should react to it are elsewhere: the label above it, the hint below it, the fieldset around it, the progress indicator at the top of the form, and the submit button at the bottom. Before :has(), wiring those together took a validation script that toggled classes on every input event. This page shows how to drive every one of those reactions from the input's own state in CSS, when to use :user-invalid instead of :invalid, and where CSS must stop and native validation takes over. It is part of the Mastering Container Queries & Responsive Layouts guide.

Why the state belongs to the input

Constraint validation already runs in the browser. An <input type="email" required> knows at every moment whether it is valid, and exposes that through pseudo-classes: :valid, :invalid, :user-valid, :user-invalid, :placeholder-shown, :checked. A script that re-derives the same state and copies it onto a class duplicates the browser's work and introduces lag: the class is updated a tick after the value changes, and any code path that forgets to update it leaves the two out of sync.

:has() lets any ancestor read that state directly. The field group can ask "do I contain an input the user has made invalid?" and style itself accordingly. The form can ask the same question about all of its fields at once. Nothing is copied, so nothing can drift.

What CSS cannot do is change behaviour. It cannot actually disable a button, prevent submission, or move focus to the first error. Those remain the job of the browser's built-in validation or a small script, and the accessibility section below draws that line carefully.

The state graph a field moves through

:user-invalid is the pseudo-class that makes CSS-only validation humane. It matches an invalid field only after the user has interacted with it — typically after they change the value and blur, or after an attempted submit. Before that point, an empty required field is :invalid but not :user-invalid.

When :user-invalid starts matching A pristine empty field matches :invalid but not :user-invalid. After the user types a bad value and leaves, it matches :user-invalid. After correcting it, it matches :user-valid. A required email field over time Pristine :invalid ✓ :user-invalid ✗ no error shown Touched, bad :invalid ✓ :user-invalid ✓ error shown Corrected :valid ✓ :user-valid ✓ success shown type + blur fix value Style the group with :has(:user-invalid), never :has(:invalid), or every required field is red before the user has typed.

That distinction is the difference between a form that scolds and one that helps. :has(:invalid) on a group paints every required field red on page load. :has(:user-invalid) waits until the user has actually made a mistake.

The complete implementation

The form below uses no script. Each field group reveals its own error, the fieldset shows a count-style summary, and the submit row changes its message once everything is valid.

Live demoField groups styled from input validity
Type a bad email and Tab away: only that group turns red. Fix both fields and the hint changes to Ready to submit.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Validation with :has()</title>
<style>
  body { font: 15px/1.5 system-ui, sans-serif; margin: 1.5rem; }

  .form { display: grid; gap: 1rem; max-width: 26rem; }

  .field { display: grid; gap: 0.25rem; }
  .field input {
    padding: 0.5rem 0.6rem;
    border: 1px solid #94a3b8;
    border-radius: 6px;
    font: inherit;
  }

  /* The error text is always in the DOM (for aria-describedby) but
     visually collapsed until the group holds a user-invalid input. */
  .field__error {
    margin: 0;
    font-size: 0.85rem;
    color: #b91c1c;
    display: none;
  }

  /* Group reacts to its own input's state. */
  .field:has(input:user-invalid) input { border-color: #b91c1c; }
  .field:has(input:user-invalid) .field__error { display: block; }
  .field:has(input:user-valid) input { border-color: #15803d; }

  /* The label gains a marker without any extra markup. */
  .field:has(input:user-invalid) label::after {
    content: " (needs attention)";
    color: #b91c1c;
    font-weight: 600;
  }

  /* Form-level summary: the submit row changes once nothing is invalid.
     :invalid (not :user-invalid) is correct HERE, because the question
     is "is the whole form complete?", not "did the user make an error?". */
  .submit-row__hint::before { content: "Complete all fields to continue."; }
  .form:not(:has(:invalid)) .submit-row__hint::before { content: "Ready to submit."; }

  .form:has(:invalid) button[type="submit"] { opacity: 0.6; }

  .field input:focus-visible,
  button:focus-visible { outline: 2px solid #2d5bff; outline-offset: 2px; }
</style>
</head>
<body>
  <form class="form" action="/signup" method="post">
    <div class="field">
      <label for="email">Email</label>
      <input id="email" name="email" type="email" required aria-describedby="email-err">
      <p class="field__error" id="email-err">Enter an email address like name@example.com.</p>
    </div>
    <div class="field">
      <label for="code">Invite code</label>
      <input id="code" name="code" required pattern="[A-Z]{4}-[0-9]{4}" aria-describedby="code-err">
      <p class="field__error" id="code-err">Use the format ABCD-1234.</p>
    </div>
    <div class="submit-row">
      <button type="submit">Create account</button>
      <p class="submit-row__hint" aria-hidden="true"></p>
    </div>
  </form>
</body>
</html>

Note the deliberate switch between :user-invalid and :invalid. Field-level errors use the user-aware version because they are feedback about a mistake. The form-level hint uses plain :invalid because it answers a different question — is the form complete? — which is legitimate to answer from the start. Mixing them up is the most common error in CSS-only validation.

The key technique: one state, many consumers

The pattern generalises to a simple architecture: the input is the single source of truth, and every piece of UI that cares about it is an ancestor that asks with :has(). The diagram shows the three levels at which this form reads the same underlying validity.

Validity read at three levels Nested boxes for form, fieldset and field group surround an input. Each level uses a different has selector to read the input's validity: the field group shows its own error, the fieldset marks its legend, and the form updates the submit hint. The input is the only source of truth form:not(:has(:invalid)) submit hint fieldset:has(:user-invalid) legend marker .field:has(input:user-invalid) own error text input:user-invalid

This is the same inversion that powers quantity queries with (): the ancestor is the subject, the descendant's state is the condition. The difference is that validity is dynamic, so the selectors re-match on every change the user makes, with no event handlers anywhere.

Accessibility: what CSS can and cannot promise

Visual feedback is only half of accessible validation. Four rules keep the CSS version honest:

  1. Keep error text in the DOM and reference it with aria-describedby, as the example does. When the field receives focus, a screen reader reads the description. Text injected only with ::after is not reliably exposed as a description.
  2. Do not rely on colour alone. WCAG 1.4.1 Use of Color requires a non-colour cue, which is why the label gains the words "needs attention" rather than just turning red.
  3. Do not fake a disabled button. Dimming the submit button with CSS changes nothing for keyboard or screen-reader users; the button is still enabled and still submits. Let the browser's native validation block submission — it focuses the first invalid field and announces its message — or use a script.
  4. Announce errors on submit. Native validation handles this. If you suppress it with novalidate, you take on the job of moving focus and announcing the error summary.

The accessible focus ring on the inputs matters here too: an error state often changes the border colour, and a focus indicator that relies on the same border becomes ambiguous. A separate outline, as covered in creating accessible focus indicators, keeps the two signals distinct.

Variation: conditional sections from a checkbox

Validity is not the only input state worth reading. A checkbox or radio can reveal a dependent section of the form, so a "Ship to a different address" checkbox shows the extra fields with no script.

/* The dependent fields are hidden until the controlling checkbox is checked. */
.shipping-extra { display: none; }

.form:has(#different-address:checked) .shipping-extra {
  display: grid;
  gap: 0.75rem;
}

/* A gentle entry for the revealed block, skipped for reduced motion. */
@media (prefers-reduced-motion: no-preference) {
  .form:has(#different-address:checked) .shipping-extra {
    animation: reveal 0.2s ease-out;
  }
}

@keyframes reveal {
  from { opacity: 0; translate: 0 -0.25rem; }
  to   { opacity: 1; translate: 0 0; }
}

Hidden fields that are required would block submission even while invisible, so either remove required from fields in a hidden section or disable them — which does need a script, since CSS cannot toggle attributes. The same reveal can be animated more elaborately with @starting-style entry animations, which transition from display: none without a keyframe rule.

Browser support

:has() ships in Chrome and Edge 105+, Firefox 121+ and Safari 15.4+. :user-invalid ships in Chrome and Edge 119+, Firefox 88+ and Safari 16.5+ — Firefox had it years before the others. Where :user-invalid is missing, the field-level rules simply do not match and the form shows no inline errors, while native validation still runs on submit. Wrap the enhancements in @supports selector(:user-invalid) if you want an explicit fallback, such as showing the hint text permanently.

FAQ

Why use :user-invalid instead of :invalid inside :has()? An empty required field is :invalid the moment the page loads, so a form styled with :has(:invalid) greets the user with red errors before they have typed anything. :user-invalid only matches after the user has interacted with the field and moved on, which is when an error message is actually helpful.

Can CSS disable the submit button while the form is invalid? CSS can make it look disabled and block pointer clicks, but it cannot stop keyboard submission or change the button's disabled state, so assistive technology would still report it as enabled. Keep native validation or a script as the real gate, and use :has() only for visual feedback.

Do screen readers hear errors revealed with :has()? Only if the message is in the accessibility tree and associated with the field. Keep the error text in the DOM, link it with aria-describedby, and reveal it visually with CSS. Content that switches from display: none to visible is not automatically announced, so pair it with native validation messages or a live region for critical errors.

Does :has() re-evaluate on every keystroke? The browser re-evaluates it when the matched state changes, and validity can change on each keystroke. Engines optimise :has() invalidation for pseudo-classes like these, and a form's DOM is small, so in practice the cost is negligible.

Related articles

More pages in the same section.