Web development

:has() for form states, with no classes from JavaScript

:has() restyles a form from the state of its fields, with no class added by JavaScript. The pattern, the difference from :invalid and the real limits.

Key points

  • :has() reads the field state and applies it to the container: the form reacts with no state line in JavaScript.
  • :invalid fires on the first render; :user-invalid waits until the user touches the field.
  • Inside a closed shadow root the selector cannot see the fields, so there you need the class from JavaScript.

Short answer: :has() lets you restyle a form from the state of its fields, with no class added by JavaScript. A rule such as .field:has(input:user-invalid) .hint is already the whole pattern.

The pattern in four rules

.field:has(input:user-invalid) {
  --ring: crimson;
}

.field:has(input:user-invalid) .hint {
  display: block;
}

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

form:has(input:checked) .extra {
  display: block;
}
StateSelectorEffect
Invalid field after interaction.field:has(:user-invalid)border and message
At least one invalid fieldform:has(:user-invalid)dimmed button
Option selectedform:has(input:checked)shows the extra field

Why :invalid is not enough

:invalid fires on the first render, before anyone touches a field: the form opens red and the user has done nothing. :user-invalid waits for interaction or a submit attempt. Where it is unsupported, keep :invalid and add a class from the JavaScript you already ship, instead of a selector polyfill.

The limitation

:has() does not cross a closed shadow root and cannot be used inside a pseudo-element. When the fields live in a web component with closed shadow DOM, the selector cannot see the state and the pattern stops at the boundary. That is the case to check before promising the behaviour to a designer.

The message must exist without CSS

Hiding the message with display: none hides it from screen readers too, and the rule that reveals it must agree with aria-describedby. Put the text in the DOM, wired to the field, and let CSS handle visibility only.

How to check it

  • Fill a field, empty it and move the focus: the message must appear afterwards, not before.
  • Submit with an empty field: the button must stay reachable by keyboard, dimmed or not.
  • Try a screen reader: the message must be announced, not only visible.
  • Turn CSS off: the form must stay fillable and understandable.

Where to continue

In the catalogue: form blocks, login block and components. The guides: the parent selector with :has() and focus-visible in Tailwind; the comparison: Tailwind against CSS Modules.

Frequently asked questions

Can I drop state in React completely?

For visual validation yes, because the browser already knows the field states. Messages that depend on a server response still need state.

What is the difference from :valid?

:valid is the opposite of :invalid and has the same timing problem. :user-valid and :user-invalid wait for interaction, so they do not colour a form on open.

Does it work with fields generated by a library?

Yes when the field sits in the page DOM. No when it lives in a closed shadow root, because :has() does not cross the boundary.

Is it expensive on a long form?

On a form with a few dozen fields the cost is negligible. On a table with thousands of inputs, measure with the Performance panel.

Does a dimmed button stay clickable?

With <code>opacity</code> yes, and that is what you want: the click must reveal the errors. <code>pointer-events: none</code> removes the feedback, so do not use it alone.