Somewhere in a stylesheet you own there is a rule with :has() in it that has never applied. Not intermittently — never. It reads correctly, it survived review, and the browser discarded the whole declaration block before painting anything, because one name inside the parentheses was a selector it did not recognise.
That is not how pseudo-classes normally behave, and it is not the only place :has() breaks the pattern it appears to belong to. It changes the specificity of a rule from inside its own parentheses. Its argument is relative rather than absolute, so a combinator written there means something different than it means anywhere else. And it is the one selector whose evaluation cost lands on elements other than the ones you named.
Four behaviours, none of them visible in the syntax. Hold all four and the advanced patterns — selecting backwards, quantity queries, lifting state to the root — stop being tricks you memorise and become things you can derive.
The Parentheses Do Not Forgive
:is() and :where() take forgiving selector lists. Hand either one a selector the browser has never heard of and it drops that argument, keeps the rest, and applies the rule. That forgiveness is why a stray vendor-prefixed selector no longer poisons an entire block.
:has() does not work that way. Its argument is a non-forgiving relative selector list: one unrecognised name and the whole selector is invalid, and an invalid selector takes its declarations down with it.
Here is the shape that bites. You want a panel to react when a popover inside it opens, so you cover both the native state and a legacy class, the way you would anywhere else. The first rule below is the dead one:
/* Dead in any engine without :popover-open — fallback and all */
.panel:has(:popover-open, .is-open) {
z-index: 2;
}
/* Alive: :is() forgives the unknown name, .is-open still matches */
.panel:has(:is(:popover-open, .is-open)) {
z-index: 2;
}
The .is-open fallback in the first rule was written specifically to serve older browsers, and it is thrown out alongside the very thing it was covering for. Not narrowed. Thrown out.
Breaks
An unknown name anywhere in the list invalidates the entire selector, so the legacy fallback sitting right beside it never gets a chance to match.
Holds
Wrapping the list in :is() supplies the forgiveness yourself. The unknown argument is dropped and the fallback survives.
Keep that wrapper in your fingers. Any time a :has() argument is a list containing something newer than your support floor, wrap the list.
Three other things invalidate a :has() selector in exactly the same silent way, and each of them looks reasonable on the page:
- A pseudo-element inside the parentheses.
li:has(::marker)is not a narrower selector, it is a broken one. - A nested
:has()..a:has(.b:has(.c))is invalid — the recursion is disallowed, not merely discouraged. :has()hanging off a pseudo-element, as in.a::before:has(.b). That never had a meaning to begin with.
And when you want a real fallback branch rather than a wrapped list, @supports understands selectors directly: @supports not selector(:has(a)) gives you somewhere to put the old markup.
!
Chrome and Edge 105+, Safari 15.4+, Firefox 121+.
Every current engine ships :has() itself. What needs checking is whatever you put inside the parentheses.
The Anchor, Not The Subject
Every other pseudo-class takes an absolute selector or no argument at all. :has() takes a relative selector list, which means two things at once: each argument is evaluated starting from the element being tested, and each argument may begin with a combinator. Leave the combinator off and a descendant combinator is implied.
.field:has(input:invalid) /* any invalid input anywhere inside */
.field:has(> input:invalid) /* only a direct child */
For the descendant and child forms there is an exact JavaScript equivalent, and it is the cleanest way to hold the model in your head:
// .field:has(> input:invalid)
field.querySelector(":scope > input:invalid") !== null;
The element is the scope. The argument is a query run from it. :has() is true when that query finds anything at all.
What it never does is move the subject. The element that receives the declarations is still the one to the left of the colon — .field, not the input that made the condition true. People call :has() the parent selector, and it is one, in roughly the way a metal detector is a shovel. It tells you the parent is the element you want. You still have to have written the parent down.
Fig. 02 — The condition looks inward; the declarations land on the box you named.
That distinction stops being pedantic the moment one condition needs to style two different elements. You cannot chain your way there. You either write the condition twice, once on each subject, or you move it somewhere both subjects can see it.
The other half of the model is that the anchor stays put even when the combinator points sideways. :has(+ .error) does not mean “an error next to something inside me”. It means “the element immediately after me is an error”. The condition travels outward from the subject in whatever direction the combinator points, and the subject never moves.
Forward Conditions, Backward Matches
Every combinator in CSS points one way. You can style the element after a heading, never the one before it, and that asymmetry has shaped a decade of markup: the extra wrapper, the duplicated class, the :last-child hack that exists only because you could not reach backwards.
Put a sibling combinator inside :has() and the asymmetry inverts. The condition still points forward. The match still lands on the anchor. So a forward condition becomes a backward selection — call it the Reversal, because it is the same mechanism every time you meet it.
/* Steps that come before the current one */
.step:has(~ .step[aria-current="step"]) {
text-decoration: line-through;
}
/* The label immediately preceding a required input */
.label:has(+ input:required)::after {
content: " *";
}
/* Seven fields or more: go two up */
.fieldset:has(> .field:nth-child(7)) {
display: grid;
grid-template-columns: 1fr 1fr;
}
/* Nothing attached yet */
.attachments:not(:has(> li)) {
display: none;
}
None of those four was expressible before, and none needs a class kept in sync from JavaScript. The second is the best deal of the lot: the asterisk is now derived from the required attribute rather than maintained beside it, so the two can no longer disagree with each other.
The third one is the quantity query, and it works because :nth-child(7) matches only if a seventh child exists. Testing for that element’s existence is therefore testing the count. Wrap it in :not() for “fewer than seven”, or chain two for a range: :has(> .field:nth-child(7)):not(:has(> .field:nth-child(13))) applies to between seven and twelve fields and nothing else.
A forward condition on the anchor is a backward selection on the page.
— Selector_Doctrine // §2.1
The fourth deserves a note, because it corrects something most of us have wrong. :empty and :not(:has(*)) are not the same test. :empty fails if there is a single whitespace text node between the tags, which is to say it fails on nearly every server-rendered list. :not(:has(*)) asks only about elements, so a container holding nothing but formatting whitespace still counts as empty.
Specificity Leaks From The Argument
:has() contributes no specificity of its own. Its argument does — the most specific argument in the list, exactly like :is(). That is a small rule with a long reach, because you choose the argument for what it matches, not for what it weighs.
| Selector | Specificity | Why |
|---|---|---|
.row:has(img) | 0-1-1 | Class plus type |
.row:has(.badge) | 0-2-0 | Two classes |
.row:has(#hero) | 1-1-0 | The id comes along |
.row:has(.badge, #hero) | 1-1-0 | Heaviest argument wins |
.row:has(:where(.badge, #hero)) | 0-1-0 | :where() weighs zero |
Read the fourth row again. A list inside :has() takes its weight from its heaviest member even when the member that actually matched was the light one. The rule’s position in the cascade then depends on a selector that had nothing to do with the match, and the utility class you wrote to override it quietly loses.
:where() fixes both problems at once. It weighs nothing, and its list is forgiving, which makes a single wrapper the answer to this section and to the first one:
.panel:has(:where(:popover-open, .is-open)) {
z-index: 2;
}
My rule of thumb: bare :has() when the argument is one simple selector whose weight you are happy to pay for. :where() inside it whenever the argument is a list, contains an id, or contains anything newer than your support floor.
Lifting State To The Root
Once the anchor model is in place, the most useful thing :has() does is turn a fact about a deep element into a fact about the document. State Lifting: rather than mirroring DOM state into a class on <html> from JavaScript, you read the state where it already lives.
html:has(dialog[open]) {
overflow: hidden;
}
:root:has(#compact-mode:checked) {
--row-height: 2rem;
}
The first is the scroll lock that every modal implementation writes by hand, expressed as what it actually is — a consequence of a descendant’s state rather than a side effect somebody has to remember to undo. The dialog closes, the open attribute goes, the lock goes with it. There is no cleanup path left to get wrong.
The second shows the reach of the pattern: a custom property set at the root cascades to everything below, so one checkbox can retune a whole layout without a single class toggle.
Two constraints before you build on this. :has() does not cross shadow boundaries, so a web component’s internal state is invisible to a rule on :root unless the component reflects it onto the host. And the condition is only as reliable as the state it reads: dialog[open] is trustworthy because the browser maintains it, while a data-open you set yourself is exactly as trustworthy as the code that sets it.
Worth knowing that the same expression works unchanged in document.querySelector, so the condition you wrote in CSS is also the assertion you can make in a test.
Where The Cost Actually Lands
Every other selector can be decided from an element and its ancestors, which is why a style engine can walk the tree downward and be done. :has() inverts the direction. Whether .field matches depends on elements below it and after it, so a change down there has to be able to reach back up and mark .field as stale.
Engines keep bookkeeping so they do not re-run every :has() rule on every mutation. That bookkeeping is not free, and the shape of your selector decides how much of it applies. Two statements I am confident in, and no numbers, because the numbers are a property of your tree rather than of :has().
The cost scales with how many elements could change the answer, not with how many rules you wrote. And a tight condition on a specific subject stays cheap at any depth, while a loose condition on a generic subject is where this goes wrong.
Which turns into four habits:
- Prefer
>and+over descendant and~where the structure allows it. A child condition can only be disturbed by a child. - Give
:has()a real subject..field:has(…)is a narrow question,div:has(…)is a broad one, and*:has(…)is a promise to check everything. - Keep the argument specific.
:has(input:invalid)asks about inputs.:has(:invalid)asks about every form-associated element in the subtree, for an answer you probably wanted from one of them. - Measure Recalculate Style in the Performance panel, before and after, on the page that worried you. That number is worth more than any rule of thumb, including these four.
The patterns are the easy part, and they all fall out of the same reading. What took me longest was accepting that the parentheses are a different language — relative, unforgiving, and louder in the cascade than they look — and that every clever thing :has() can do follows from treating them that way.