LEARNING OBJECTIVES ⌵
- Understand why
:has()was called the "Holy Grail" of CSS and why it took over 20 years to implement across browser engines. - Master the syntax of
:has()for parent-to-child (parent:has(child)), ancestor-to-descendant, and preceding sibling relationships. - Construct dynamic, reactive UI layouts (such as conditionally styling form submit buttons or card layouts) purely in CSS without JavaScript event listeners.
- Calculate the specificity of
:has()rules and understand best practices for browser selector engine performance.
📖 The Mental Model & Story (Intuitive Foundation)
Imagine a passenger train conductor walking through train carriages.
For 25 years, the rules of CSS said: "You can only give instructions forward from the locomotive to the passengers." A stylesheet could say: "If this train carriage has first-class passengers, make their individual seats leather" (.carriage .seat).
However, you could never ask: "If any passenger in this carriage has a bicycle, attach a luggage rack to the carriage itself." That would require a passenger (the child) to change the properties of the train carriage (the parent). For decades, web developers were forced to write dozens of lines of JavaScript (element.closest(), classList.toggle(), MutationObserver) just to style a parent container based on what was inside it.
The :has() Relational Pseudo-Class revolutionized CSS by turning it into a two-way relational query language. Now, a parent element can inspect its descendants or siblings: "If I contain an image, switch my layout to two columns; if any input inside me is invalid, lock the submit button."
Technical Deep Dive & Specifications
Why :has() Took 20 Years to Standardize
The concept of a "parent selector" existed in early CSS proposals in the late 1990s. However, browser engine architects (in Gecko and WebKit) faced severe style invalidation performance bottlenecks:
THE CSS ENGINE RECALCULATION LOOP
1. User types in <input>
2. <input> becomes :invalid
3. With :has(), the browser must check ALL ancestor nodes (form, section, main, body, html)
to see if any ancestor style rules depend on :has(:invalid).
4. In the 1990s/2000s, this triggered recursive layout thrashing on single-core CPUs.
In 2022–2023, browser engineers in WebKit (Safari), Blink (Chromium), and Gecko (Firefox) implemented optimized bloom filters and invalidation sets, allowing :has() to execute at near-instantaneous O(1) speeds, making :has() available across all modern evergreen browsers.
The Syntax Grammar of :has()
The argument inside :has() is a relative selector list:
+---------------------------------------------------------------------------------------------------+
| THE :has() SELECTOR SUITE |
+--------------------------+------------------------------------------------------------------------+
| Selector Pattern | Matches When... |
+--------------------------+------------------------------------------------------------------------+
| `article:has(img)` | An `<article>` contains an `<img>` anywhere in its subtree (descendant)|
| `article:has(> img)` | An `<article>` contains an `<img>` as a DIRECT child |
| `figure:has(figcaption)` | A `<figure>` that contains a caption |
| `h2:has(+ p)` | An `<h2>` that is IMMEDIATELY followed by a `<p>` sibling |
| `h2:has(~ .alert)` | An `<h2>` that is followed by an `.alert` sibling anywhere downstream |
| `form:has(:invalid)` | A `<form>` where ANY input or field is currently in an invalid state |
+--------------------------+------------------------------------------------------------------------+
ANATOMY OF A RELATIONAL QUERY
article:has(> figure.hero-img)
\_____/ \____________________/
| |
Target Relative Match Condition
Parent (Has direct child figure with class)
Specificity Calculation for :has()
Like :is() and :not(), the :has() pseudo-class itself contributes nothing; it assumes the highest specificity of the selectors passed inside its argument list:
+---------------------------------------------------------------------------------------------------+
| SPECIFICITY CALCULATION |
+------------------------------------+----------------+---------------------------------------------+
| Selector | Specificity | Reason |
+------------------------------------+----------------+---------------------------------------------+
| `article:has(img)` | (0, 0, 0, 2) | `article` (1 element) + `img` (1 element) |
| `article:has(.badge)` | (0, 0, 1, 1) | `article` (1 element) + `.badge` (1 class) |
| `article:has(#featured)` | (0, 1, 0, 1) | `article` (1 element) + `#featured` (1 ID) |
| `form:has(input:invalid)` | (0, 0, 1, 2) | 2 Elements + 1 Pseudo-Class (`:invalid`) |
+------------------------------------+----------------+---------------------------------------------+
💻 Interactive Code Playground
Starter Code
Line-by-Line Code Breakdown
- Line 28 (
.card:has(img)): Inspects whether the.cardcontains an<img>tag anywhere in its subtree. If true, it strips card padding (padding: 0) and setsoverflow: hiddento create a modern edge-to-edge media banner. - Line 43 (
.card:has(.badge--gold)): Checks if the card contains an element with.badge--gold, applying an amber box-shadow glow to the entire card container. - Line 77 (
.form-container:has(input:invalid) .submit-btn): A relational query. When any input inside.form-containerfails HTML validation constraints (required,minlength="4",type="email"), the submit button turns gray and displays anot-allowedcursor. - Line 90 (
.form-container:has(input:invalid) .form-notice): Dynamically reveals the warning notice banner whenever the form has invalid inputs.
Expected Browser Render Output
1. Parent-Reactive Cards
+------------------------+ +------------------------+ +------------------------+
| Text Announcement | | [ Full Bleed Photo ] | | [ FEATURED BADGE ] |
| Standard Card Padding | | Media Spotlight | | Enterprise System |
| | | Card with 0 padding | | (Glowing Amber Border) |
+------------------------+ +------------------------+ +------------------------+
2. Form Reactive Validation
[ Username: (empty) ]
[ Email: (empty) ]
[ Submit Application (Disabled Gray Button) ]
⚠️ Complete all required fields with valid input to enable submission.🏋️ Hands-On Exercise
🎯 The Challenge: Build a Dynamic Dark Mode Page & Interactive Table of Contents
Instructions:
- Create a pure-CSS dark/light theme switcher using
body:has(#theme-checkbox:checked).- When checked,
bodyshould switch background to#ffffffand text color to#0f172a.
- When checked,
- Build an interactive Table of Contents (
<nav class="toc">) where hovering over any link highlights the corresponding heading in the article using relational sibling selectors (body:has(.toc a[href="#section-1"]:hover) #section-1). - Style an editorial blockquote so that if it contains a
cite(blockquote:has(cite)), it adds an author divider border and extra bottom margin.
🏁 Starter Code Sandbox
⚠️ Common Pitfalls
- Nesting
:has()Inside:has(): Writing:has(:has(.child))is explicitly forbidden by the CSS Selectors Level 4 specification and will cause the selector to fail parsing. - Attempting Pseudo-Elements inside
:has(): Pseudo-elements (like::before,::after) cannot be passed inside:has();article:has(::before)is invalid. - Creating Expensive Unbounded Wildcards: Writing
*:has(*)forces the browser engine to perform relational subtree checks on every element on the page, significantly slowing down DOM updates. Always scope:has()to a specific class or tag (.card:has(img)).
💡 Pro Tips
- Zero-JS Sticky Header Detection: Combine
:has()with intersection observer flags or checkbox toggles to change navigation bar appearance instantly. - Preceding Sibling Selection: While CSS still lacks a preceding sibling combinator (
-), you can simulate one with:has():
/* Selects a <p> that is followed immediately by an <h2> */
p:has(+ h2) {
margin-bottom: 0.25rem;
}
📌 Key Takeaways
:has()is a relational pseudo-class that allows an element to style itself based on its descendants or subsequent siblings.- Supported in all modern evergreen browsers (Chrome, Safari, Firefox, Edge).
- The specificity of
:has()is determined by the most specific argument in its selector list. :has()eliminates the need for JavaScript state toggling in common patterns like form validation, parent layout shifts, and theme toggling.- You can simulate a previous-sibling selector using
element:has(+ sibling). - --