CSS selectors have always described a path downward or sideways through the DOM — never upward. :has() breaks that constraint, letting you style an element based on what exists inside, next to, or notably absent from it.
1The Feature CSS Was Missing For Two Decades
Every other CSS selector describes a relationship in one direction: a descendant combinator selects something *inside* an ancestor, a sibling combinator selects something *after* a sibling. None of them let you select an ancestor *based on* what's inside it — that always required JavaScript to inspect the DOM and toggle a class manually.
:has() closes this gap directly. .card:has(img) reads naturally: select a .card, but only if it has an <img> inside it. The element ultimately styled is the one before :has(), not the one inside its argument — a genuinely new kind of relationship CSS selectors can now express.
2Practical Patterns: Validation, Empty States, Siblings
The most immediately useful real-world pattern is form validation styling: form:has(:invalid) lets a form container show an error state the moment any field inside becomes invalid, with zero JavaScript. The inverse pattern, .list:not(:has(li)), detects an empty list by checking for the *absence* of any list item — useful for showing a placeholder without a script tracking list length.
:has() also composes with sibling combinators: h2:has(+ p) matches an <h2> only when immediately followed by a <p>, letting typography rules adapt based on what comes next in the document, another relationship previously impossible in pure CSS.
3Specificity Rules And Sensible Performance Boundaries
:has() follows the same specificity model as :is() and :not(): the pseudo-class itself contributes zero specificity on its own, but the selector(s) inside its argument list contribute their specificity, with the highest one counted. .card:has(#featured) therefore carries the specificity of an ID selector, even though .card alone would normally be much lower.
On performance, modern browser engines have invested specifically in optimizing :has(), and typical use cases (checking a handful of children) are fast. The caution applies to genuinely unbounded queries — using :has() to search deep, large subtrees on every style recalculation is measurably more expensive than a direct selector, so it's worth reserving for conditions that are genuinely relational rather than reaching for it as a default habit.
4Step-by-Step Breakdown
The Parent Selector CSS Never Had. For over two decades, 'style the parent of X' was the single most requested CSS feature, and the answer was always 'use JavaScript'. The :has() relational pseudo-class finally closes that gap: it matches an element if any selector passed to it matches something inside — including descendants, siblings, or even nothing at all.
Matching A Parent By Its Contents. :has() is applied to the element you want to select, and its argument describes what must exist relative to it. .card:has(img) selects a .card that contains an <img> anywhere inside it — the selector's target and its condition are on opposite ends of the relationship for the first time in CSS.
The Parent Selector. What does .card:has(img) select?
- →Every <img> element inside a .card
- →Every .card element that contains an <img> anywhere inside it
- →Every element immediately after a .card containing an img
Beyond Descendants: Siblings And Absence. :has() isn't limited to descendant checks. Combined with sibling combinators it can express 'style this element if a specific sibling follows it', and combined with :not() it can express absence — form:has(:invalid) for a form containing any invalid field, or .list:not(:has(li)) for an empty list.
Sibling And Absence Checks. What does form:has(:invalid) match?
- →Every invalid field, regardless of its form
- →The <form> element itself, if it contains at least one currently invalid field
- →Only forms where every field is valid
A Meaningful Specificity And Performance Note. :has() takes on the specificity of its most specific argument, just like :is(). Performance-wise, browsers optimize it well for common cases, but an unbounded :has(*) style query over a huge subtree is measurably more expensive than a plain selector — reserve it for genuinely relational conditions, not as a blanket replacement for simpler selectors.
:has() Specificity. How does :has() contribute to a selector's overall specificity?
- →It always contributes zero specificity, like :where()
- →It contributes the specificity of the most specific selector inside its argument
- →It always contributes a fixed, maximum specificity regardless of its argument
Relational Selection Unlocked. You can now select an element based on its descendants, siblings, or their absence — the single most-requested CSS capability for over twenty years, delivered without a single line of JavaScript. Use it deliberately for genuinely relational conditions like form validation states or content-aware layouts.
Style A Parent Based On Its Children. :has() lets a selector match a parent based on what it contains — a real 'parent selector' at last.
Level Up 🚀
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1:has() Can Replace JavaScript-Toggled Classes For Purely Visual State, Reducing Script Complexity
Using form:has(:invalid) instead of a JS-added .has-error class means one less place visual state and actual validity can drift out of sync, since the CSS reads live DOM validity state directly.
2:has()-Driven Style Changes Must Still Be Paired With Accessible Error Messaging
Styling a form's border red when it :has(:invalid) fields is a visual-only signal; it must be paired with programmatically associated error text (via aria-describedby) so the same information reaches screen reader users.
SEO Implications
- 1
:has() Can Eliminate JavaScript Previously Required Purely For Conditional Styling
Replacing a ResizeObserver or DOM-inspection script with a native :has() rule reduces JavaScript bundle size and main-thread work, which are both inputs into Core Web Vitals metrics search engines weigh.
- 2
Unbounded :has() Queries On Very Large Pages Can Measurably Affect Style Recalculation Time
On pages with thousands of DOM nodes, broad :has() conditions checked against large subtrees can add to layout/style recalculation cost, worth profiling on content-heavy pages before shipping broadly.
Best Practices
Reach For :has() To Replace JavaScript DOM-Inspection Hacks First
Many existing 'parent selector' JavaScript workarounds (adding a class to a parent based on child state) can be deleted entirely and replaced with a single :has() rule, reducing both code and a class of state-sync bugs.
Scope :has() Queries As Narrowly As Reasonably Possible
Prefer .form:has(:invalid) over body:has(.form :invalid) — narrower relational scope is both clearer intent and cheaper for the browser to evaluate repeatedly.
Frequent Bugs
A form's error-state border doesn't clear after a user fixes the invalid field.
:has(:invalid) is live and re-evaluates automatically — if it's not clearing, the field's actual validity (not just its visual appearance) likely still fails HTML validation constraints.
A broad :has() selector is suspected of causing a noticeable style recalculation slowdown on a large page.
Narrow the :has() scope to a smaller, more specific ancestor instead of querying from a very high-level container across the whole subtree.
Real-World Examples
Zero-JavaScript Form Validation Styling
A signup form that highlights itself in red the instant any required field is left invalid, using only native HTML validation attributes and a single :has() rule.
form:has(:invalid:not(:placeholder-shown)) {
border: 2px solid #dc2626;
}