Style wrappers, labels and fieldsets based on the state of their child elements
For decades, CSS could not select a parent element based on the state of one of its children. The :has() selector closes exactly this gap and makes form wrappers, fieldsets and entire form sections reactive to :invalid, :user-invalid and :checked, with not a single line of JavaScript.
Table of Contents
- 1. Why styling parent elements was impossible until now
- 2. :has() as a relational pseudo-class, briefly explained
- 3. Recipe: styling a form field wrapper based on its child state
- 4. Recipe: highlighting a selected option in a radio or checkbox group
- 5. Recipe: a password wrapper with a multi-step strength indicator
- 6. Form-wide states: controlling the submit button and a summary hint
- 7. Specificity and performance aspects of :has()
- 8. Why :has(:user-invalid) is almost always the better choice
- 9. Browser support and a fallback for older browsers
- 10. Summary
- 11. FAQ
1. Why styling parent elements was impossible until now
CSS selectors like input:invalid only ever style the element they directly match, never one of its ancestor elements. For a typical form layout, where an <input> sits inside a surrounding <div> together with a label and an icon, that rule was not enough to color the entire wrapper based on the input field's validation state.
Before :has(), the only option left was JavaScript that added or removed a class like .has-error on the wrapper for every relevant event. That worked, but tied the visual presentation firmly to custom script code that had to be written and maintained again for every new form component.
2. :has() as a relational pseudo-class, briefly explained
The :has() pseudo-class is often called the long-missing parent selector, but it is actually more general: it checks whether, anywhere inside the selected element, an element exists that matches the given selector, no matter how deeply nested. div:has(input:invalid) therefore selects any div that contains an invalid input field anywhere in its descendant tree.
This check works live and updates automatically as soon as the child element's validation state changes, exactly like any other CSS pseudo-class. It is not a one-time check on page load, but a continuously re-evaluated condition that reacts to every keystroke and every focus change.
3. Recipe: styling a form field wrapper based on its child state
A typical form field consists of a wrapper div containing a label, the actual <input>, and an error icon. With .field:has(input:user-invalid) the entire wrapper, including label color and icon visibility, can be controlled in a single rule instead of addressing every child element individually with its own selector.
It matters to deliberately choose :user-invalid over :invalid inside the :has() parentheses, so the whole wrapper is not already colored red when the page loads. This combination links the timing benefits of interaction-based validation directly with the structural power of :has(), with the two concepts never contradicting each other.
.field {
border-inline-start: 3px solid transparent;
padding-inline-start: 0.75rem;
transition: border-color 0.15s ease;
}
.field:has(input:user-invalid) {
border-inline-start-color: #dc2626;
}
.field:has(input:user-invalid) label {
color: #dc2626;
font-weight: 600;
}
.field:has(input:user-invalid) .field-icon {
display: block;
color: #dc2626;
}
.field:has(input:user-valid) {
border-inline-start-color: #16a34a;
}
4. Recipe: highlighting a selected option in a radio or checkbox group
For a group of radio buttons inside a <fieldset>, fieldset:has(input:checked) checks whether any option has been chosen at all, which works well for visually emphasizing a required-selection rule as long as nothing has been picked yet.
Things get more precise with a selector on each individual option row: .option:has(input:checked) highlights exactly the one selected row in a list of card-style radio options, for example with a colored border and a slightly raised shadow, without JavaScript ever having to manually track the selection.
.option {
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1rem;
cursor: pointer;
}
.option:has(input:checked) {
border-color: #4a1d96;
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.25);
background-color: #f5f3ff;
}
fieldset:not(:has(input:checked)) .required-hint {
display: block;
color: #b45309;
}
5. Recipe: a password wrapper with a multi-step strength indicator
A password field wrapper can combine several :has() conditions to visualize a rough strength estimate, by checking different pattern attributes on hidden helper inputs, or directly through :has(input:valid) combined with additional attribute selectors. For most projects, a simple two-tier distinction between met and unmet minimum requirements is enough.
More realistic and maintainable is usually a combination of :has() for the wrapper coloring with a minimum criterion checked server-side or via pattern, while a true multi-step strength calculation that weighs different criteria is still best left to JavaScript. :has() does not replace every piece of logic here, but reliably handles the purely visual link between state and wrapper presentation.
6. Form-wide states: controlling the submit button and a summary hint
Beyond the boundary of a single field, form:has(:user-invalid) checks whether any field in the entire form is currently invalid and has already been touched. This condition works great for automatically showing and hiding a summary error banner at the top of the form, without a single script needing to track the form state.
Similarly, the submit button can be visually marked as not yet ready as long as form:has(:invalid) applies, regardless of whether the fields have already been touched. This combination of a restrained button style before interaction and a clear error message after interaction delivers a coherent overall picture, without manually synchronizing the two states in JavaScript.
7. Specificity and performance aspects of :has()
Regarding specificity, :has() behaves like other functional pseudo-classes: specificity matches that of the most specific selector inside the parentheses, not that of :has() itself. div:has(input:invalid) therefore has the same specificity as div input:invalid, which matters when prioritizing against other rules.
On the performance side, browsers had to develop new optimization strategies for :has(), because evaluation, unlike most other selectors, does not just look at a single element but searches its entire descendant tree. In practice this stays unproblematic for typical form wrappers with just a few nested levels, but should be deliberately tested for very large, deeply nested lists with hundreds of elements.
8. Why :has(:user-invalid) is almost always the better choice
Just like for a single input field, the same principle applies to wrapper elements: :has(input:invalid) marks the wrapper already on page load once a required field is empty, while :has(input:user-invalid) waits for real user interaction. For form UX, the latter is practically always the right choice, because otherwise the wrapper shows the exact same premature error picture that a single field without this distinction would also show.
An exception is pure display widgets outside of forms, for example a dashboard tile meant to show whether a technical error state exists anywhere in the system, independent of any user interaction. There, :has(:invalid) without the user variant makes perfect sense, because it is not about form input feedback but about a purely technical system state.
9. Browser support and a fallback for older browsers
The :has() selector is supported by all current versions of Chrome, Firefox and Safari, after Firefox caught up as the last of the three major browser engines. For projects that still need to support older browser versions, :has() simply has no effect in unsupported browsers, the form keeps working, just without the additional wrapper coloring.
Anyone who still needs a comparable visual result for older browsers combines :has() as the preferred solution with a lean JavaScript class as a fallback, activated only when CSS.supports('selector(:has(*))') returns false. That way the common case stays fully declarative, while only a small minority of legacy browsers ever load the JavaScript path at all.
| Recipe | Selector | Use case |
|---|---|---|
| Coloring a field wrapper | .field:has(input:user-invalid) |
Controlling label, border and icon of a form field together |
| Highlighting a selected option | .option:has(input:checked) |
Visually marking card-style radio or checkbox options |
| Required-selection hint | fieldset:not(:has(input:checked)) .required-hint |
Showing a hint text as long as no option has been selected |
| Form-wide error banner | form:has(:user-invalid) .summary-banner |
Showing a summary hint only on real errors after interaction |
| Submit button state | form:has(:invalid) button[type=submit] |
Visually marking the button as not yet ready |
Mironsoft
Modern CSS, layout architecture and rendering performance
CSS that stays maintainable instead of breaking with every change?
We review existing stylesheets for specificity chaos and layout thrashing, then build a CSS architecture with cascade layers, custom properties and modern layout primitives that still makes sense after the tenth feature.
CSS Audit
Systematically uncovering specificity issues, cascade conflicts and unused selectors.
Architecture Refactoring
Introducing cascade layers, custom properties and design tokens cleanly.
Performance Tuning
Fixing layout thrashing, expensive selectors and rendering bottlenecks.
10. Summary
:has() for Form Validation: The Essentials at a Glance
Core idea
:has() checks whether an element in the descendant tree matches a selector, making parent elements reactive to child states.
Wrapper recipe
.field:has(input:user-invalid) controls a form field's label, border and icon in a single rule.
Fieldset recipe
.option:has(input:checked) highlights the selected card option in radio or checkbox groups.
Combining with user-invalid
:has(input:user-invalid) instead of :has(input:invalid) prevents premature error marking of the whole wrapper.