:has() Selector Performance: What Is Cheap, What Is Not, and How to Measure
For twenty years the stated reason CSS had no parent selector was performance: browsers match selectors right to left, from the element outward, and a selector that depends on descendants would force them to re-examine ancestors every time anything inside changed. :has() shipped anyway because engine teams found ways to make that re-examination targeted. It is fast in ordinary use — but "ordinary use" has limits, and a few patterns can turn a hover into a full-document style recalculation. This page explains how invalidation works, which shapes of :has() stay cheap, and how to verify a suspicious rule in DevTools. It belongs to Parent-Aware Layouts With
Why the worry existed
Selector matching runs right to left. For .card .title, the engine starts at each element that could be .title and walks up looking for a .card ancestor. Ancestors are few and walking up is cheap. A relational selector reverses that: for .card:has(.title), the subject is .card, and deciding whether it matches means looking down into a subtree that may contain thousands of elements.
Matching once is not the real problem — pages are styled once and the cost is bounded. The problem is invalidation. When the DOM or an element's state changes, the engine must work out which elements' styles might now be different. For ordinary selectors, a change to an element can only affect that element, its descendants, and sometimes its siblings. With :has(), a change deep in a subtree can affect ancestors all the way up to the root. Without care, every class toggle anywhere would force a style recalculation of everything above it.
How engines keep it targeted
The engines solve this with bookkeeping at stylesheet-parse time. For every :has() in the stylesheet, they record which simple selectors appear in its argument — the classes, attributes, pseudo-classes and element types. When something changes, they first ask a cheap question: could this change affect any :has() argument? A class that appears in no argument is ignored completely. Only if the changed feature is relevant do they walk up the ancestors looking for elements whose :has() might now match differently, and they mark only those for recalculation.
That design has a clear consequence for authors. The cost of a :has() rule is not paid when it matches; it is paid when something that appears in its argument changes. A rule whose argument names a rarely changing feature is essentially free. A rule whose argument names something that changes on every mouse move is paid on every mouse move — and how much it costs then depends on how broad the anchor is.
The complete implementation: cheap and expensive side by side
The stylesheet below contains pairs of rules that produce similar visual results with very different invalidation costs. It is annotated rule by rule.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>:has() cost comparison</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 1.5rem; }
.list { display: grid; gap: 0.25rem; max-width: 24rem; padding: 0; list-style: none; }
.list li { padding: 0.4rem 0.6rem; border-radius: 6px; }
/* EXPENSIVE: the anchor is the whole document and the argument is :hover,
which changes constantly. Every hover change anywhere inside body may
affect body's match, so style is invalidated from the root down. */
body:has(.list li:hover) .toolbar { opacity: 0.6; }
/* CHEAP: same visual intent, anchor narrowed to the component and the
argument scoped to direct children. A hover only re-checks this .list. */
.panel:has(> .list > li:hover) > .toolbar { opacity: 0.6; }
/* CHEAP even at the root: the argument changes rarely. Opening a dialog
happens a handful of times per session, so a document-wide recalc
at that moment is fine. */
:root:has(dialog[open]) { overflow: hidden; }
/* RISKY: a universal subject means every element is an anchor candidate.
Always give :has() a specific subject. */
/* *:has(> img) { outline: 1px solid red; } -- avoid */
figure:has(> img) { margin-inline: 0; }
/* PREFER a direct-child argument when that is what you mean. It is
both clearer and gives the engine a shorter walk. */
.card:has(> .card__media) { grid-template-rows: auto 1fr; }
</style>
</head>
<body>
<section class="panel">
<div class="toolbar">Toolbar</div>
<ul class="list">
<li>First item</li>
<li>Second item</li>
<li>Third item</li>
</ul>
</section>
</body>
</html>
The first two rules are the heart of the matter. Both dim a toolbar while any list item is hovered. The first anchors on body, so each hover change is potentially relevant to the document root, and the resulting style invalidation can cascade through everything body contains. The second anchors on the panel that actually contains both the list and the toolbar, so each hover change walks up at most two levels and recalculates one small subtree.
The key technique: narrow the anchor, stabilise the argument
Cost is a product of two things — how often the argument changes, and how much style depends on the anchor. The chart below plots common patterns on those two axes. Anything in the top-right quadrant deserves a second look.
Practical rules follow directly:
- Give every
:has()a specific subject. A class or element type, never*and rarelybody. The subject determines which elements are candidates. - Scope the argument with a combinator.
:has(> x)or:has(+ x)when that is what you mean. It prevents accidental matches from nested components and shortens the walk. - Use document-level anchors only for stable state.
:root:has(dialog[open]),:root:has(#dark-mode:checked)and similar page-mode switches change a few times per session. - Avoid volatile pseudo-classes under broad anchors.
:hover,:focusand:activechange constantly; keep their:has()anchors close to the element that changes. - Beware of
:has()combined with sibling selectors..a:has(~ .b)makes every later sibling relevant to every earlier one, which is fine in a short list and unpleasant in a list of thousands.
Measuring instead of guessing
Rules of thumb only get you so far; profile when a page feels slow. The workflow below uses Chrome's Performance panel, which exposes per-selector timing.
- In Chrome or Edge DevTools, open the Performance panel, open its capture settings, and enable the selector statistics option. It adds overhead, so turn it off afterwards.
- Record while performing the exact interaction that feels slow — hovering down a list, typing in a field, toggling a panel.
- In the flame chart, find the purple Recalculate Style events and select a long one. The summary shows how many elements were affected.
- Open the Selector Stats tab for that event and sort by elapsed time. Each row shows a selector, how long it took, how many match attempts were made, and how many matched. A
:has()rule with enormous match attempts and few matches is the one to narrow.
Firefox's profiler shows style recalculation cost as well, without per-selector breakdown; if the style phase is large after a change and removing one :has() rule shrinks it, you have your answer. The broader animation-profiling workflow is covered in Profiling Animations in DevTools.
Variation: replacing a hot :has() with a container style query
When a broad anchor genuinely needs state from deep inside — a page layout that changes when a sidebar panel is expanded, say — and the state changes often, consider moving the state into a custom property and reading it with a style query. The component that owns the state sets a property on itself, the layout container reads it, and the relationship is explicit rather than discovered by selector walking.
/* The panel that owns the state publishes it as a custom property... */
.sidebar { container-name: shell; }
.sidebar:has(> details[open]) { --sidebar-state: expanded; }
/* ...and descendants of the container read it with a style query.
The :has() is anchored narrowly on .sidebar, and only the
container's subtree consults the published value. */
@container shell style(--sidebar-state: expanded) {
.sidebar__nav { grid-template-columns: 1fr; }
}
Whether this is actually faster depends on the page, so measure both. The architectural benefit is certain, though: the state has a name, and anyone reading the stylesheet can see which component publishes it. The trade-offs between the two mechanisms are laid out in
Browser support
:has() is supported in Chrome and Edge 105+, Firefox 121+ and Safari 15.4+, and all three engines implement targeted invalidation of the kind described here. Selector statistics in the Performance panel is a Chromium DevTools feature. Style queries for custom properties, used in the variation, are supported in Chrome and Edge 111+, Firefox 151+ and Safari 18+.
FAQ
Is :has() slow?
Not in normal use. Engines track which elements could be affected by a :has() argument and only recheck those when something relevant changes. It becomes expensive when the anchor is very broad, such as body or :root, and the argument matches something that changes often, because then every change forces a recheck over a large subtree.
Is body:has() or :root:has() a bad idea?
It is fine for state that changes rarely, such as a dialog being open or a theme checkbox being checked. It is a poor choice for state that changes constantly, such as :hover inside a long list, because every hover change then invalidates style for the whole document.
Does the child combinator make :has() faster?
Usually. :has(> .x) only needs to look at direct children, while :has(.x) must consider the whole subtree. Engines optimise both, but a narrower argument means fewer elements to recheck and less chance of accidental matches deep inside nested components.
How do I measure whether a :has() rule is causing jank?
Record a Performance profile while triggering the state change and look at the Recalculate Style events. Chrome's selector statistics option lists the selectors that took the most time and how many elements they were tested against, which pinpoints an expensive :has() directly.
Related
- Parent-Aware Layouts With
() — the parent guide on relational selectors. - Quantity Queries With
() — a stable-argument pattern that is always cheap. () vs Container Style Queries — when published state beats discovered state. - Profiling Animations in DevTools — the same panel, used for motion.
- Will-Change and the Compositor Thread — what happens after style recalculation.
Related articles
More pages in the same section.