Skip to content

The CSS :has() Selector, Explained With Real Patterns

Real :has() patterns for form validation, card states, and navigation, plus where it still needs a JavaScript fallback and how to check browser support.

· · 5 min read

Quick Take

For fifteen years, CSS couldn't style a parent based on its children. I filed that away as a permanent limitation. Then :has() shipped in every major browser, and half the JavaScript I used to write for hover states and form validation just... became CSS.

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

TaskBefore :has()With :has()
Style a label when its input is invalidJS event listener + class toggle.field:has(input:invalid)
Card layout depends on contentJS querySelector check on render.card:has(img)
Dim siblings on hoverJS mouseover on parent, class per siblingnav:has(li:hover) li:not(:hover)
Detect empty state in a listJS check .length === 0ul: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.

Frequently Asked Questions

What does the CSS :has() selector actually do?
It matches an element based on what's inside it or next to it. `.card:has(img)` selects a `.card` element only if it contains an `img` anywhere inside. `.card:has(+ .card)` selects a `.card` that's immediately followed by another `.card`. Before :has(), CSS could only select downward (an element's descendants) or forward (later siblings), never upward to a parent based on its contents. :has() is often called the 'parent selector', which is the single most requested CSS feature for over a decade.
Is :has() safe to use in production in 2026?
Yes. It shipped in Chrome and Edge in December 2022, Safari in early 2023, and Firefox in December 2023. As of 2026, it has full support across all evergreen browsers with no vendor prefix needed. The only caveat is very old browser versions still in enterprise environments, in which case wrap the rule in an `@supports selector(:has(a))` block and provide a JavaScript fallback for those specific cases.
Does :has() hurt CSS performance?
Browsers optimized the common cases well, form validation and simple sibling checks show no measurable difference from ordinary selectors in real-world testing. Where it can get expensive is deeply nested or overly broad selectors, like `body:has(.deeply .nested .selector)` evaluated against a huge DOM, because the browser has to check the subtree on every relevant DOM mutation. Keep :has() selectors scoped to a specific, shallow subtree and you won't notice a difference.