When it actually gets expensive
With :has(), CSS can for the first time select an element based on its descendants or following siblings, a capability the language waited years for. That power comes at a cost though: :has() forces the browser to search an entire subtree for every candidate instead of checking a single node, which can become noticeable in large DOM trees.
Table of Contents
- 1. What :has() can do and why it differs fundamentally from other selectors
- 2. Why :has() creates more work during style matching
- 3. Typical :has() use cases and their respective cost profile
- 4. The decisive factor: DOM size and change frequency
- 5. Measurement methods: making :has() style recalculation cost visible
- 6. Optimization strategies: actively limiting the :has() search space
- 7. When a JavaScript alternative makes more sense than :has()
- 8. Browser support and progressive safeguarding with @supports selector()
- 9. :has() compared to classic selectors and JavaScript class toggling
- 10. Summary
- 11. FAQ
1. What :has() can do and why it differs fundamentally from other selectors
The :has() selector checks whether an element has at least one descendant or following sibling that matches a given selector, and if so, selects the original element itself. An expression like .card:has(img) matches every .card that contains an img element anywhere in its descendant tree, regardless of how deeply that image is nested.
Almost every other CSS selector checks a fixed, known-in-advance relationship: an element itself, a direct child, a direct sibling. :has(), in contrast, checks an open-ended question across an entire, potentially arbitrarily deep subtree, and that structural openness is exactly the root of its higher matching cost compared to classic selectors.
2. Why :has() creates more work during style matching
The browser evaluates normal CSS selectors from right to left and only needs to look up along the ancestor chain of a single candidate, a path with a clearly bounded, usually small length. With :has(), however, the browser must search the complete descendant subtree of every candidate that qualifies as the base element, to determine whether the :has() expression is satisfied anywhere inside it, a potentially much larger and structurally unbounded search space.
Browser engines like Blink and WebKit counter this extra cost with internal optimizations, for example by re-evaluating only the actually affected ancestors when a descendant element changes, instead of re-searching the entire tree on every DOM change. These optimizations reduce the cost substantially but do not eliminate it entirely: the fundamental algorithmic complexity stays higher than for a selector that only checks a single, fixed path.
3. Typical :has() use cases and their respective cost profile
A common, practically unproblematic use case is a form field container styled based on the validation state of a contained input, for example .field:has(input:invalid). Since forms usually contain a manageable number of fields and the nesting depth between container and input stays shallow, the subtree that has to be searched is small, and the extra cost compared to a classic selector stays practically unmeasurable.
An expression like body:has(.modal-open) becomes considerably more expensive not because of the selector structure itself, but because of the size of the affected subtree: the body element sits at the root of the entire document, which means the browser may have to search the entire DOM tree for a matching .modal-open element. In a very large, deeply nested application with thousands of elements, that search space can end up significantly larger than in the localized form field example.
/* Cheap: small, shallow search space inside a form field */
.field:has(input:invalid) {
border-color: #dc2626;
}
/* Potentially expensive: body as the root element forces the
browser to search the entire DOM tree in the worst case */
body:has(.modal-open) {
overflow: hidden;
}
4. The decisive factor: DOM size and change frequency
The mere presence of :has() in the stylesheet costs nothing as long as the affected subtree does not change. It only becomes expensive once elements inside the watched subtree change frequently, for example through classes being added and removed dynamically on every user interaction, because the browser has to re-check whether the result of the :has() expression changed on every such update.
The combination of a large DOM and a high change frequency is particularly critical: a long, virtualized list with thousands of entries where every keystroke in a filter field changes classes on many list items at once, paired with a :has() selector reacting to one of those states, can add up to noticeable delays, while the same structure remains unproblematic on a static page with a few hundred elements.
5. Measurement methods: making :has() style recalculation cost visible
The Chrome DevTools Performance tab shows the time spent on style recalculations under Recalculate Style. A targeted A/B comparison, running the same interaction once with a :has() selector and once with a functionally equivalent, JavaScript-based class toggle, makes the actual extra cost on the specific page visible, instead of relying on general claims.
A synthetic stress test with an artificially enlarged DOM helps as well: render the same component once in a document with a hundred elements and once with ten thousand, comparing the style recalculation time for an identical interaction. If the cost curve scales markedly disproportionately with DOM size, that is a clear signal to deliberately rework the affected :has() selector.
/* Narrowing the search space instead of a global body selector */
.app-shell:has(.modal-open) {
overflow: hidden;
}
/* Even narrower: only the direct layout wrapper */
.layout-root:has(> .modal-open) {
overflow: hidden;
}
6. Optimization strategies: actively limiting the :has() search space
The most effective optimization is anchoring the :has() expression as close as possible to the actually relevant structure, instead of applying it to a very high-level element like body or html. A more specific base selector like .app-shell:has(.modal-open) instead of body:has(.modal-open) considerably reduces the subtree that has to be searched, provided .app-shell already sits closer to the actually affected structure.
A direct child combinator inside the :has() argument, for example :has(> .item) instead of :has(.item), helps as well, because the browser then only has to check direct children instead of the entire, arbitrarily deep descendant tree. Where the application logic allows for it, this restriction to direct children is often the simplest and most effective optimization, without changing the actual use case of the selector.
7. When a JavaScript alternative makes more sense than :has()
For very large, highly dynamic DOM trees, for example data tables with tens of thousands of rows where a :has() expression would need to be re-evaluated on every user interaction, a targeted JavaScript solution that sets a state class once on the relevant target element can be cheaper than the ongoing :has() matching cost across the entire tree. The JavaScript approach shifts the cost from repeated selector evaluation to a one-time, deliberate DOM manipulation.
For the vast majority of use cases, though, especially forms, individual card components and localized UI states, :has() remains the considerably lower-maintenance and usually also more performant solution, since it needs no additional JavaScript logic, no event listeners, and no manual synchronization of CSS classes with application state.
8. Browser support and progressive safeguarding with @supports selector()
All current versions of Chrome, Edge, Firefox and Safari now fully support :has(), so plain availability is no longer an obstacle for most projects. For projects that still need to support older browser versions, a safeguard is still worth adding, because a browser without :has() support ignores the affected rule entirely instead of falling back to alternative behavior.
The @supports selector(:has(a)) feature specifically checks whether the browser supports the :has() selector itself, separate from the general @supports check for properties and values. That makes it possible to activate an alternative, usually JavaScript-based path exclusively for browsers that genuinely do not know :has(), without burdening modern browsers with unnecessary extra code.
/* Baseline behavior without :has(), works everywhere */
.field {
border-color: #cbd5e1;
}
/* Enhancement only for browsers that support :has() as a selector */
@supports selector(:has(a)) {
.field:has(input:invalid) {
border-color: #dc2626;
}
}
9. :has() compared to classic selectors and JavaScript class toggling
The overview below ranks the three common approaches by matching cost, maintainability and the context each one fits best.
| Approach | Matching cost | Maintainability | Recommended context |
|---|---|---|---|
| Classic selector | Constant, independent of DOM size | High, no special rules needed | All standard cases without a descendant condition |
| :has() with a narrow base selector | Low to moderate, depends on the subtree | High, no extra JavaScript code | Form fields, cards, localized UI states |
| :has() with body or html as base | Potentially high with a large DOM | Medium, but works without JS | Small to medium pages, infrequent changes |
| JavaScript class toggling | One-time on change, no ongoing matching | Lower, extra code and event handling required | Very large, highly dynamic DOM trees |
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() Performance: The Essentials at a Glance
Core idea
:has() checks an entire descendant subtree instead of a single fixed path, which structurally creates more matching cost.
Decisive factor
It is not the mere presence of :has() that is expensive, but the combination of a large affected subtree and frequent changes within it.
Optimization
A narrow base selector instead of body or html, plus direct child combinators inside the :has() argument, noticeably reduce the search space.
Measurement
The Recalculate Style value in the Chrome DevTools Performance tab makes the actual extra cost on a specific page visible.