For nearly three decades, CSS could target an element based on its ancestors, its siblings, or its own attributes, but never on what it contained. Styling a <section> differently depending on whether it held an image, for instance, meant reaching for a JavaScript-toggled class. The :has() pseudo-class fills that gap: natively available in major browsers since December 2023, it lets you select a parent element, or an element based on what follows it.
Basic syntax
:has() takes a list of relative selectors as an argument and matches the element if at least one of those selectors finds a match below or beside it. Think of it as CSS’s equivalent of a regex lookahead: you select an element based on what accompanies it, without ever directly targeting that accompanying element.
Targeting what follows an element
Combined with a sibling combinator, :has() lets you react to a following element, something CSS simply couldn’t do before:
/* Tightens the margin of an h1 immediately followed by an h2 */
h1:has(+ h2) {
margin-bottom: 0.25rem;
}
/* Combined with :is() across several heading levels */
:is(h1, h2, h3):has(+ :is(h2, h3, h4)) {
margin-bottom: 0.25rem;
}This is one of the most commonly cited use cases: automatically tightening the spacing between a heading and the subheading that directly follows it, with no utility class to add in the markup for every occurrence.
Forms and interface components
The most widespread production use case involves forms and interactive components, places where JavaScript used to exist solely to toggle a state class. Paired with container queries, which already adapt a component to the size of its container, :has() goes further by adapting it to its content too:
/* Highlights an invalid form field */
.form-field:has(input:invalid) {
border-color: var(--color-danger);
}
/* Changes a product card's appearance when a checkbox is checked */
.product-card:has(input[type="checkbox"]:checked) {
outline: 2px solid var(--accent);
}
/* AND logic: both conditions must be true */
body:has(video):has(audio) {
--has-rich-media: 1;
}Detecting an empty state without JavaScript
Another frequent production pattern is showing an empty-state message when a list has no items, a task that used to require counting children server-side or in JavaScript. Combined with :not(), :has() expresses that absence directly:
/* Shows a default message if .list contains no .item */
.list:not(:has(.item)) .empty-state {
display: block;
}
.list:has(.item) .empty-state {
display: none;
}The same principle applies to an empty shopping cart, an inbox with no messages, or a dashboard with no configured widget: cases where “this element contains nothing” used to be a JavaScript check run after every DOM update, and can now stay declarative and update automatically with the content.
Browser support
:has() is part of the “Baseline” set of features, meaning it’s available with no prefix or flag across all major browsers since December 2023. Support spans both desktop and mobile versions of:
| Browser | Minimum version |
|---|---|
| Chrome / Edge | 105 and later |
| Safari (desktop and iOS) | 15.4 and later |
| Firefox | 121 and later |
| Opera | 91 and later |
For a project that still needs to support older versions of these browsers, a capability test via @supports selector(:has(a)) allows for a clean fallback instead of letting an entire stylesheet rule fail silently.
Specificity and limits to know about
:has() takes the specificity of the most specific selector passed to it, exactly like :is() and :not(). Two limits are worth keeping in mind before adopting it broadly: it cannot be nested inside another :has(), and pseudo-elements aren’t valid selectors inside its parentheses, which avoids conditional dependency loops.
The most common compatibility trap: if :has() isn’t supported by the browser, the entire selector block fails silently, not just the :has() portion. For a safer fallback on code that still needs to run on older browsers, wrapping it in :is() or :where() limits the side effects, and a support test via @supports selector(:has(a)) remains the most explicit approach.
The performance trap to avoid
Because :has() forces the rendering engine to examine an element’s descendants or siblings before deciding whether it matches, an overly broad anchor can trigger costly subtree traversals on large pages.
:has() moves into CSS a logic that used to require listening for events in JavaScript just to toggle a state class.
When JavaScript still wins
:has() doesn’t replace everything. Interactions that depend on application state, async data, or business logic still belong in JavaScript; likewise, light/dark theme decisions still rely on design tokens rather than content selection. But anything that only existed to add or remove a class reflecting the DOM’s internal state — a filled field, a checked box, a present or absent child — becomes a natural candidate for :has(), with less JavaScript to maintain and less risk of the class drifting out of sync with the DOM’s real state.
Key takeaways
:has() is natively available across all recent versions of major browsers since December 2023 and can already replace a chunk of the utility JavaScript dedicated to managing state classes. Its use demands the same discipline as any combined selector: anchor on a precise container, restrict the inner part with direct combinators, and reserve :has() for cases where the relationship between the element and its content is genuinely structural.
This is the kind of CSS feature that doesn’t make noise but quietly reshapes a good chunk of component habits once you start using it. On my recent projects I’ve replaced several small bits of JavaScript that only existed to toggle a state class with a plain :has(), with a clear readability gain. The real risk is overusing broad selectors out of convenience: it runs fine on a lightweight page in development, and it shows a lot less gracefully once the page is loaded with real content — Simon Janvier.
Further reading: the MDN documentation for the :has() selector.
