DEV Community

HK Lee
HK Lee

Posted on • Originally published at pockit.tools

CSS :has() Selector Deep Dive: The Parent Selector That Changes Everything

For over two decades, web developers have asked for one thing: a parent selector in CSS. The ability to style an element based on its children seemed like an impossible dream. JavaScript was our only escape hatch, leading to countless workarounds, state management headaches, and performance compromises.

Then came :has().

The CSS :has() selector fundamentally changes how we think about styling web interfaces. It's not just a parent selector—it's a relational pseudo-class that lets you select elements based on what they contain, what comes after them, or virtually any relationship you can express in CSS.

In this comprehensive guide, we'll explore everything you need to know about :has(): from basic syntax to advanced patterns, performance considerations, and real-world applications that will transform your CSS architecture.

The Long Wait for a Parent Selector

Before diving into :has(), let's understand why this feature took so long to arrive and why it matters so much.

The Historical Problem

Traditional CSS selectors work in one direction: from parent to child. You can style a child based on its parent, but never the reverse.

/* This works - styling children based on parent */
.card .title {
  font-size: 1.5rem;
}

/* This was impossible - styling parent based on child */
/* .card:contains(.featured-badge) { ... } ← NOT VALID CSS (before :has()) */
Enter fullscreen mode Exit fullscreen mode

This limitation forced developers into awkward patterns:

  1. Adding modifier classes: .card.has-featured-badge { ... }
  2. JavaScript-based styling: Listening for DOM changes and toggling classes
  3. Restructuring HTML: Moving elements around to fit CSS limitations

Each approach had drawbacks. Modifier classes created coupling between logic and styles. JavaScript solutions added complexity and potential performance issues. HTML restructuring compromised semantic markup.

Why It Took So Long

Browser vendors hesitated to implement parent selectors due to performance concerns. CSS selectors are evaluated right-to-left for efficiency. A parent selector could theoretically require the browser to:

  1. Find all matching descendant elements
  2. Walk up the DOM tree for each match
  3. Apply styles to ancestors

This approach could cause significant rendering bottlenecks on complex pages. The :has() specification addresses these concerns with specific parsing and evaluation rules, making it performant enough for real-world use.

Understanding :has() Fundamentals

The :has() pseudo-class accepts a relative selector list as its argument and matches elements that have at least one descendant matching that selector.

Basic Syntax

parent:has(child-selector) {
  /* styles applied to parent */
}
Enter fullscreen mode Exit fullscreen mode

The element before :has() is styled when it contains any element matching the selector inside the parentheses.

Your First :has() Example

Let's start with a practical example. Consider a card component:

<div class="card">
  <img src="product.jpg" alt="Product">
  <h3>Product Title</h3>
  <p>Product description...</p>
</div>

<div class="card">
  <h3>Text-Only Card</h3>
  <p>This card has no image.</p>
</div>
Enter fullscreen mode Exit fullscreen mode

Previously, styling cards differently based on whether they contain an image required JavaScript or separate CSS classes. With :has():

/* Default card styles */
.card {
  padding: 1rem;
  border-radius: 8px;
  background: white;
}

/* Cards WITH images get a different layout */
.card:has(img) {
  display: grid;
  grid-template-columns: 200px 1fr;
  gap: 1rem;
}

/* Cards WITHOUT images center their content */
.card:not(:has(img)) {
  text-align: center;
  max-width: 400px;
}
Enter fullscreen mode Exit fullscreen mode

This is pure CSS. No JavaScript. No extra classes. The styling adapts automatically based on content.

Beyond Parent Selection: :has() as a Relational Selector

While "parent selector" captures the most common use case, :has() is far more powerful. It's a relational pseudo-class that can express complex element relationships.

Sibling Selection with :has()

You can select elements based on their siblings using :has() with sibling combinators:

/* Select a label that has a required input after it */
label:has(+ input:required) {
  font-weight: bold;
}

label:has(+ input:required)::after {
  content: " *";
  color: #e74c3c;
}
Enter fullscreen mode Exit fullscreen mode

This pattern is incredibly useful for form styling without JavaScript.

Ancestor Selection

:has() can look multiple levels deep:

/* Style a section if it contains ANY error anywhere inside */
.form-section:has(.error-message) {
  border-left: 4px solid #e74c3c;
  background: #fdf2f2;
}

/* Style a table row if it contains an editable cell */
tr:has(td[contenteditable="true"]) {
  background: #fffef0;
}
Enter fullscreen mode Exit fullscreen mode

Combining Multiple Conditions

You can chain multiple :has() conditions:

/* Card with both an image AND a featured badge */
.card:has(img):has(.featured-badge) {
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
  border: 2px solid gold;
}
Enter fullscreen mode Exit fullscreen mode

Or use a selector list within :has():

/* Card with either a video OR an image */
.card:has(img, video) {
  min-height: 300px;
}
Enter fullscreen mode Exit fullscreen mode

Real-World Use Cases and Patterns

Let's explore practical applications that showcase the true power of :has().

1. Form Validation Styling

One of the most impactful uses of :has() is CSS-only form validation feedback:

/* Style form group based on input validity */
.form-group:has(input:invalid:not(:placeholder-shown)) {
  --input-border-color: #e74c3c;
  --input-bg: #fdf2f2;
}

.form-group:has(input:valid:not(:placeholder-shown)) {
  --input-border-color: #27ae60;
  --input-bg: #f0fdf4;
}

.form-group input {
  border: 2px solid var(--input-border-color, #ddd);
  background: var(--input-bg, white);
  transition: all 0.2s ease;
}

/* Show validation message only when invalid */
.form-group .error-message {
  display: none;
  color: #e74c3c;
  font-size: 0.875rem;
  margin-top: 0.5rem;
}

.form-group:has(input:invalid:not(:placeholder-shown)) .error-message {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

This creates a complete, reactive validation UI without a single line of JavaScript.

2. Navigation Active States

Style navigation items based on the current page or active state:

/* Highlight dropdown parent when any child link is active */
.nav-dropdown:has(.nav-link.active) > .dropdown-toggle {
  color: var(--primary-color);
  font-weight: bold;
}

/* Show dropdown indicator when it has submenu items */
.nav-item:has(.submenu)::after {
  content: "▼";
  font-size: 0.75em;
  margin-left: 0.5rem;
}
Enter fullscreen mode Exit fullscreen mode

3. Empty State Handling

Detect and style empty containers:

/* Style container when it has no items */
.item-grid:not(:has(.item)) {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 300px;
}

.item-grid:not(:has(.item))::before {
  content: "No items to display";
  color: #999;
  font-style: italic;
}
Enter fullscreen mode Exit fullscreen mode

4. Quantity Queries

This is where :has() gets creative. You can style elements based on sibling count:

/* Style differently when there's only one item */
.item:only-child {
  width: 100%;
}

/* When there are exactly two items */
.item:first-child:nth-last-child(2),
.item:last-child:nth-last-child(2) {
  width: 50%;
}

/* Style the container when it has more than 3 items */
.item-container:has(.item:nth-child(4)) {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
Enter fullscreen mode Exit fullscreen mode

5. Focus Management

Implement advanced focus states for accessibility:

/* Highlight entire card when any focusable element inside is focused */
.card:has(:focus-visible) {
  outline: 3px solid var(--focus-color);
  outline-offset: 2px;
}

/* Dim other cards when one is focused (for keyboard navigation) */
.card-container:has(.card:focus-visible) .card:not(:focus-visible):not(:has(:focus-visible)) {
  opacity: 0.7;
}
Enter fullscreen mode Exit fullscreen mode

6. Responsive Component Behavior

Create components that adapt based on their content:

/* Switch header layout based on content */
.header:has(.search-bar):has(.user-menu) {
  display: grid;
  grid-template-columns: auto 1fr auto;
}

.header:has(.search-bar):not(:has(.user-menu)) {
  display: grid;
  grid-template-columns: auto 1fr;
}

/* Adjust sidebar width when expanded sections exist */
.sidebar:has(.section.expanded) {
  width: 320px;
}

.sidebar:not(:has(.section.expanded)) {
  width: 240px;
}
Enter fullscreen mode Exit fullscreen mode

7. Table Enhancements

Style tables dynamically based on content:

/* Highlight row when checkbox is checked */
tr:has(input[type="checkbox"]:checked) {
  background: #e3f2fd;
}

/* Style header differently when table has sortable columns */
table:has(th[data-sortable]) thead {
  cursor: pointer;
}

table:has(th[data-sortable]) th:hover {
  background: #f5f5f5;
}

/* Add visual indicator when table is empty */
table:not(:has(tbody tr)) {
  position: relative;
}

table:not(:has(tbody tr))::after {
  content: "No data available";
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  color: #999;
}
Enter fullscreen mode Exit fullscreen mode

Advanced Patterns and Techniques

Combining :has() with :not()

The combination of :has() and :not() unlocks powerful negation patterns:

/* Style elements that DON'T contain something */
.container:not(:has(.advertisement)) {
  /* Premium, ad-free experience styles */
  padding: 2rem;
}

/* Cards without media */
.card:not(:has(img, video, iframe)) {
  /* Text-only card styles */
  font-size: 1.1rem;
  line-height: 1.8;
}
Enter fullscreen mode Exit fullscreen mode

Debugging with :has()

Use :has() for development-time debugging:

/* Highlight images without alt text during development */
img:not([alt]),
img[alt=""] {
  outline: 5px solid red !important;
}

/* Highlight forms with empty actions */
form:not([action]),
form[action=""] {
  outline: 3px dashed orange !important;
}

/* Find links that open in new tabs without rel="noopener" */
a[target="_blank"]:not([rel*="noopener"]) {
  outline: 3px solid purple !important;
}
Enter fullscreen mode Exit fullscreen mode

The Forward-Looking Combinator Pattern

Use :has() with the next-sibling combinator for future element selection:

/* Style an element based on what comes AFTER it */
h2:has(+ .special-content) {
  /* This h2 is followed by special content */
  color: var(--accent-color);
  border-bottom: 3px solid currentColor;
}

/* Create a "previous sibling" selector effect */
.item:has(+ .item.active) {
  /* Style the item BEFORE the active one */
  border-right: none;
}
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

While modern browsers handle :has() efficiently, understanding performance implications helps you write optimized CSS.

How Browsers Evaluate :has()

Browsers implement :has() with specific optimizations:

  1. Invalidation limits: Browsers set limits on how deep :has() will search
  2. Subject-based caching: Once matched, results are cached for the subject element
  3. Mutation batching: DOM changes are batched before re-evaluation

Best Practices for Performance

1. Be Specific with Selectors

/* ❌ Broad selector - evaluates for all divs */
div:has(img) { ... }

/* ✅ Specific selector - limits scope */
.card:has(img) { ... }
Enter fullscreen mode Exit fullscreen mode

2. Avoid Deep Nesting

/* ❌ Deep nesting requires traversing entire subtree */
.page:has(.section .container .row .col .card .badge) { ... }

/* ✅ Direct or shallow relationships */
.card:has(> .badge) { ... }
Enter fullscreen mode Exit fullscreen mode

3. Use Direct Child Combinator When Possible

/* ❌ Searches all descendants */
.menu:has(.active) { ... }

/* ✅ Only checks direct children */
.menu:has(> .menu-item.active) { ... }
Enter fullscreen mode Exit fullscreen mode

4. Limit Selector List Length

/* ❌ Long selector lists in :has() */
.container:has(img, video, audio, iframe, canvas, svg, object, embed) { ... }

/* ✅ Semantic grouping with data attributes */
.container:has([data-media]) { ... }
Enter fullscreen mode Exit fullscreen mode

Measuring Performance Impact

To measure :has() performance:

// Use the Performance API
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'style-recalc') {
      console.log(`Style recalculation: ${entry.duration}ms`);
    }
  }
});

observer.observe({ entryTypes: ['measure'] });
Enter fullscreen mode Exit fullscreen mode

Or use browser DevTools:

  1. Open Performance tab
  2. Record while interacting with elements that trigger :has() re-evaluation
  3. Look for "Recalculate Style" entries

Browser Support and Fallbacks

As of late 2024, :has() enjoys excellent browser support:

  • Chrome: 105+ (August 2022)
  • Edge: 105+ (August 2022)
  • Safari: 15.4+ (March 2022)
  • Firefox: 121+ (December 2023)
  • Opera: 91+

This means over 95% of global users can use :has() today.

Creating Fallbacks

For legacy browser support, use feature queries:

/* Default fallback styles */
.card {
  display: block;
}

/* Progressive enhancement with :has() */
@supports selector(:has(*)) {
  .card:has(img) {
    display: grid;
    grid-template-columns: 200px 1fr;
  }
}
Enter fullscreen mode Exit fullscreen mode

The @supports Pattern

A robust pattern for :has() fallbacks:

/* Base styles - work everywhere */
.form-group {
  margin-bottom: 1rem;
}

.form-group.has-error {
  border-color: red;
}

/* Modern browsers with :has() support */
@supports selector(:has(*)) {
  /* Remove JavaScript-dependent class usage */
  .form-group.has-error {
    border-color: initial;
  }

  /* Use :has() instead */
  .form-group:has(input:invalid:not(:placeholder-shown)) {
    border-color: red;
  }
}
Enter fullscreen mode Exit fullscreen mode

Common Mistakes and How to Avoid Them

Mistake 1: Misunderstanding :has() Specificity

A common misconception is that :has() doesn't add to specificity. In reality, :has() takes the specificity of the most specific selector in its argument.

/* Specificity calculation */
.card { ... }           /* (0, 1, 0) */
.card:has(img) { ... }  /* (0, 1, 1) -> because img is (0, 0, 1) */
.card:has(#hero) { ... } /* (1, 1, 0) -> because #hero is (1, 0, 0) */
Enter fullscreen mode Exit fullscreen mode

The pseudo-class itself doesn't add specificity, but its content does. This is different from :where(), which always has 0 specificity.

/* If you need to boost specificity */
.card:has(img) { ... }
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Over-Reliance on :has()

Not everything needs :has(). Simpler solutions often exist:

/* ❌ Overcomplicating */
.btn:has(svg):has(span) { ... }

/* ✅ Simpler with a utility class */
.btn.icon-button { ... }
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Ignoring Selector Performance

/* ❌ Expensive - checks every element */
*:has(.some-class) { ... }

/* ✅ Scoped appropriately */
.component:has(.some-class) { ... }
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Circular Dependencies

Some selectors can create infinite loops:

/* ⚠️ Be careful with self-referential patterns */
.item:has(+ .item:has(+ .item)) { ... }
Enter fullscreen mode Exit fullscreen mode

The Future of CSS Selection

The :has() selector represents a fundamental shift in CSS capabilities, but it's just the beginning. Future CSS specifications are exploring:

  1. CSS Nesting: Now available, works beautifully with :has()
  2. Scroll-driven animations: Combine with :has() for complex scroll effects
  3. Container queries: Pair with :has() for truly responsive components
  4. CSS Mixins: Reusable style patterns that could leverage :has()

:has() with CSS Nesting

Modern CSS nesting makes :has() even more powerful:

.card {
  padding: 1rem;

  &:has(img) {
    display: grid;
    grid-template-columns: 200px 1fr;

    & img {
      border-radius: 8px;
    }
  }

  &:has(.badge) {
    position: relative;

    & .badge {
      position: absolute;
      top: -10px;
      right: -10px;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

:has() with Container Queries

The ultimate in component-based styling:

.card-container {
  container-type: inline-size;
  container-name: card;
}

@container card (min-width: 400px) {
  .card:has(img) {
    grid-template-columns: 250px 1fr;
  }
}

@container card (max-width: 399px) {
  .card:has(img) {
    grid-template-columns: 1fr;
  }
}
Enter fullscreen mode Exit fullscreen mode

Practical Refactoring: Before and After

Let's see a real-world refactoring example. Consider a navigation component:

Before :has() (JavaScript Required)

<nav class="main-nav">
  <ul class="nav-list">
    <li class="nav-item has-submenu expanded">
      <a href="#">Products</a>
      <ul class="submenu">
        <li><a href="#" class="active">Software</a></li>
        <li><a href="#">Hardware</a></li>
      </ul>
    </li>
  </ul>
</nav>
Enter fullscreen mode Exit fullscreen mode
// JavaScript required to manage these classes
document.querySelectorAll('.nav-item').forEach(item => {
  if (item.querySelector('.submenu')) {
    item.classList.add('has-submenu');
  }
  if (item.querySelector('.active')) {
    item.classList.add('expanded');
  }
});
Enter fullscreen mode Exit fullscreen mode
.nav-item.has-submenu > a::after {
  content: "▼";
}

.nav-item.expanded > .submenu {
  display: block;
}
Enter fullscreen mode Exit fullscreen mode

After :has() (Pure CSS)

<nav class="main-nav">
  <ul class="nav-list">
    <li class="nav-item">
      <a href="#">Products</a>
      <ul class="submenu">
        <li><a href="#" class="active">Software</a></li>
        <li><a href="#">Hardware</a></li>
      </ul>
    </li>
  </ul>
</nav>
Enter fullscreen mode Exit fullscreen mode
/* No JavaScript needed! */
.nav-item:has(.submenu) > a::after {
  content: "▼";
}

.nav-item:has(.active) > .submenu {
  display: block;
}

.nav-item:has(.active) > a {
  font-weight: bold;
  color: var(--primary);
}
Enter fullscreen mode Exit fullscreen mode

The benefits:

  • Cleaner HTML: No state classes
  • No JavaScript: Removes a runtime dependency
  • Automatic updates: Changes to DOM auto-update styles
  • Better performance: CSS is faster than JavaScript for this

Conclusion

The CSS :has() selector is more than a parent selector—it's a paradigm shift in how we can express styling logic. After two decades of workarounds, we finally have a native way to select elements based on their relationships with other elements.

Key Takeaways

  1. :has() is a relational pseudo-class, not just a parent selector. It can express sibling relationships, descendant conditions, and complex logical combinations.

  2. Browser support is excellent at 95%+. You can use :has() today with simple fallbacks for legacy browsers.

  3. Performance is generally not a concern for well-scoped selectors. Be specific, avoid deep nesting, and use direct child combinators when possible.

  4. :has() reduces JavaScript dependency. Many interactive patterns that required JavaScript can now be pure CSS.

  5. Combine with modern CSS features like nesting and container queries for even more powerful component-based styling.

The web platform continues to evolve, closing gaps that once required JavaScript workarounds. The :has() selector is one of the most significant additions to CSS in years, and mastering it will make you a more effective frontend developer.

Start small—find one JavaScript-dependent style pattern in your codebase and see if :has() can simplify it. You'll be surprised how often the answer is yes.


💡 Note: This article was originally published on the Pockit Blog.

Check out Pockit.tools for 50+ free developer utilities (JSON Formatter, Diff Checker, etc.) that run 100% locally in your browser.

Top comments (2)

Collapse
 
alohci profile image
Nicholas Stimpson • Edited

Mostly a very good article. But Mistake 1 is wrong. The Selectors level 4 specification says

The specificity of an :is(), :not(), or :has() pseudo-class is replaced by the specificity of the most specific complex selector in its selector list argument.

So .card:has(img) { ... } has a specificity of (0, 1, 1).

Collapse
 
pockit_tools profile image
HK Lee

Great catch! 🎯 Thanks for citing the specific spec. I totally missed that detail about :has() taking the specificity of its argument.

It is indeed (0, 1, 1). I've fixed the mistake in the post. Thanks for reading carefully and taking the time to correct me!