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;
}
| State | Selector | Effect |
|---|---|---|
| Invalid field after interaction | .field:has(:user-invalid) | border and message |
| At least one invalid field | form:has(:user-invalid) | dimmed button |
| Option selected | form: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.