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.
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.
<!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.
This is the same inversion that powers quantity queries with
Accessibility: what CSS can and cannot promise
Visual feedback is only half of accessible validation. Four rules keep the CSS version honest:
- 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::afteris not reliably exposed as a description. - 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.
- 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.
- 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
- Parent-Aware Layouts With
() — the parent guide to relational selectors. - Responsive Forms With Container Queries — laying out these fields at any width.
Form Patterns — group styling driven by focus instead of validity. () Selector Performance — why these selectors stay cheap in forms.
Related articles
More pages in the same section.