I spent years writing JavaScript to add a class to a form group when its input was invalid, just so CSS could style the label red. That's a three-line addEventListener for something that should have been a selector. :has() makes it one, and once you start using it, you stop reaching for that JavaScript pattern entirely.
TL;DR:
:has()lets CSS select a parent based on its descendants, or an element based on a following sibling, something CSS couldn't do for its entire history until December 2022. It has full browser support in 2026. The best use cases are form validation states, card layouts that react to their content, and navigation that responds to a hovered child, all previously JavaScript-only patterns.
Why :has() Was the Most Requested CSS Feature
CSS selectors have always worked in one direction: an ancestor selects a descendant (.card img), or an earlier element selects a later sibling (.item ~ .item). There was no way to select an element based on what came inside or after it. Want to style a form group differently when its checkbox is checked? You needed JavaScript, because .form-group input:checked selects the input, not the group around it.
:has() flips that. .form-group:has(input:checked) selects the .form-group, using the checkbox state inside it as the condition. It's a relational pseudo-class: it doesn't style what's inside the parentheses, it uses that as a filter for the element it's attached to.
Pattern 1: Form Validation Without JavaScript
This is the pattern that sold me on :has(). Styling an entire form field, label included, based on whether its input is invalid:
.field {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 12px;
}
.field:has(input:invalid:not(:placeholder-shown)) {
border-color: #dc2626;
background: #fef2f2;
}
.field:has(input:invalid:not(:placeholder-shown)) label {
color: #dc2626;
}
.field:has(input:valid) {
border-color: #16a34a;
}
:not(:placeholder-shown) matters here, it stops the invalid style from firing before the user has typed anything. Without it, every empty required field shows red on page load, which is worse UX than no validation styling at all.
Pattern 2: Cards That React to Their Own Content
Say you have a card grid where some cards have images and some don't, and you want different padding depending on which:
.card {
padding: 24px;
}
.card:has(img) {
padding: 0 0 16px 0; /* image goes edge-to-edge, text keeps padding */
}
.card:has(img) .card-body {
padding: 16px 24px 0;
}
No JavaScript checking if (card.querySelector('img')) and toggling a class. The CSS itself checks for the image's presence and adjusts layout accordingly.
Pattern 3: Sibling-Aware Navigation Highlighting
:has() also matches on siblings, not just descendants, which makes some navigation patterns much simpler:
/* Dim other nav items when one is hovered, without :hover on each sibling individually */
nav:has(li:hover) li:not(:hover) {
opacity: 0.5;
transition: opacity 0.15s ease;
}
This is the kind of "spotlight" hover effect that used to require a JavaScript mouseover listener on the parent, toggling classes on siblings. Now it's two selectors.
Pattern 4: Layout That Adapts to Item Count
:has() combined with :nth-child() or count-based sibling selectors lets a grid change layout based on how many items it holds:
/* If the grid has exactly one child, make it full width */
.grid:has(> .item:only-child) {
grid-template-columns: 1fr;
}
/* If the grid has more than 4 items, switch to a denser layout */
.grid:has(> .item:nth-child(5)) {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
The :nth-child(5) trick works because :has(> .item:nth-child(5)) matches only if a fifth child exists at all, it's a count check disguised as a position selector.
Old Way vs Modern Way
| Task | Before :has() | With :has() |
|---|---|---|
| Style a label when its input is invalid | JS event listener + class toggle | .field:has(input:invalid) |
| Card layout depends on content | JS querySelector check on render | .card:has(img) |
| Dim siblings on hover | JS mouseover on parent, class per sibling | nav:has(li:hover) li:not(:hover) |
| Detect empty state in a list | JS check .length === 0 | ul:not(:has(li)) |
That last row is one I use constantly: ul:not(:has(li)) selects a <ul> with zero list items, letting you show an empty-state message purely in CSS by making the empty-state element a CSS-only sibling that shows when the list matches.
Where :has() Still Needs a JavaScript Fallback
:has() can't react to things that aren't in the DOM or reflected as an attribute, scroll position, viewport size beyond media queries, or arbitrary JavaScript state that isn't exposed as a class or data-* attribute. If your condition is "user has scrolled past 500px," that's still an IntersectionObserver, not a selector. And for the very rare enterprise environment still running a pre-2023 browser, wrap the rule:
@supports selector(:has(a)) {
.field:has(input:invalid) { border-color: #dc2626; }
}
That keeps the styling from silently failing where :has() isn't parsed at all, since an unsupported selector is simply ignored rather than causing an error.
Conclusion
:has() closed a real, long-standing gap in CSS: styling a parent based on its children, or an element based on a sibling's state. The form validation and card-content patterns above are the ones I reach for most, and both replace JavaScript that existed purely to toggle a class. If you're still writing that kind of toggle logic in 2026, check whether :has() can do it in one selector first.