jQuery shipped :has() in 1.1.4, back in 2007. The browser shipped it this March.
The obvious reading of that gap is neglect. The real answer is the direction of style invalidation, and Dave Hyatt named it in 2008. Shaun Inman proposed qualified selectors that year and Hyatt shot the proposal down on dynamics: "just not functioning in dynamic contexts... or on particularly large sample sets". Firefox has bug 418039 open since 2008. Snook declared it impossible-ish in 2010. Fourteen years of "too slow".
Safari 15.4 shipped :has() first. Chrome's Intent to Ship targets 105. Today you can play with it in Canary behind the Experimental Web Platform Features flag.
What it is
:has() matches an element by looking at its insides.
label:has(input:invalid) {
color: crimson;
}No class toggling, no MutationObserver decorating the parent. Four jobs it steals from JavaScript.
/* form states, upward */
.field:has(:focus-visible) { outline: 2px solid var(--accent); }
/* quantity queries: 8+ items, denser grid */
.grid:has(> :nth-child(8)) { grid-template-columns: repeat(4, 1fr); }
/* previous sibling, finally */
li:has(+ li:hover) { transform: scale(0.97); }
/* page-level state, no body.modal-open class */
body:has(dialog[open]) { overflow: hidden; }How they made it fast
Hyatt was right. Blink's style invalidation compiles selectors into invalidation sets. On a DOM mutation, Blink walks downward from the mutated element to find what needs restyling. :has() reverses the direction, because the subject sits above the thing that changed.
Igalia built the mirror image: ancestor invalidation sets and preceding-sibling invalidation sets, walked upward from the mutation, and across earlier siblings for :has(~ …). Byungwoo Lee did the work, funded by eyeo. His writeup is the best thing published on this.
Matching cost got the same treatment. Naive :has() matching is quadratic, because every candidate scans its own subtree. Blink matches in reverse DOM order instead and caches results per style lifecycle, with bloom-filter-style fast rejection on top. Blink took the trick from WebKit, where Antti Koivisto landed the feature.
Igalia published measurements. querySelectorAll('.a:has(.b)') costs around 1ms at 2.000 elements. The pathological non-subject form :has(.b) .a costs around 24ms at 8.000. Try their live harness yourself. The cost is real, and nothing like the disaster everyone predicted for fourteen years.
The fine print, from the spec
Per the May 2022 Selectors 4 draft, :has() takes a forgiving relative selector list, so an unknown selector inside does not kill the rule. The draft still marks the feature at-risk. Three restrictions you will hit.
- No
:has()inside:has(). The parser rejects it. The CSSWG resolved the ban this June, and the ban covers:is()and:where()wrapping a:has()too. :visitednever matches inside:has(). Otherwisebody:has(a:visited[href="https://yoursite.com"])is a history sniffer. The privacy model wins.@supports selector(:has(a))reports the truth. The CSSWG resolved that@supportsuses non-forgiving parsing, so the gate stays honest.
@supports selector(:has(a)) {
/* the good stuff */
}Firefox has no implementation and, as of today, no official position. Keep the JavaScript fallback for critical states. Let :has() carry the cosmetic ones, and delete the glue as support lands.
The parent selector was the white whale. Igalia dragged it onto the deck.