The Power of :has() - CSS Can Now Climb the DOM

Explore how CSS :has() unlocks parent-level styling and smarter UI design with just pure CSS, no JavaScript needed!

CSS has long been evolving to make styling more powerful and intuitive. One of the newest and most exciting additions to the CSS selector arsenal is the :has() pseudo-class. This selector enables parent or ancestor selection based on child elements, something that was previously impossible in pure CSS until December 2023.

In this article, we'll explore everything you need to know about :has(): what it is, how it works, its practical uses, browser compatibility, and some gotchas to watch out for.

What is the :has() Pseudo-Class?

The :has() pseudo-class selector allows you to select an element if it contains at least one element that matches the selector inside the parentheses.

In other words, it's a parent selector that matches an element based on the presence of a child or descendant.

Syntax

E:has(s) {
    /* Rules applied to E who has s */
}
  • E = the element you want to style conditionally
  • s = a selector that matches child or descendant elements inside E

Why is :has() Special?

For decades, CSS didn't had a way to style an element based on its children or descendants. You could style children based on parents, but the reverse was impossible without JavaScript.

With :has(), you get this ability natively in CSS, enabling more dynamic and context-aware styling, purely declarative and performant.

Why Use :has() Instead of JavaScript?

  • Performance: Browser optimizes CSS selectors better than DOM manipulation scripts.
  • Simplicity: Less code, fewer event listeners.
  • Maintainability: Declarative styling, easier to understand.
  • Progressive Enhancement: Works gracefully on modern browsers, doesn't break UI if unsupported.

Try It Yourself!

Here's a simple example you can test in a modern browser console or JSFiddle:

<style>
    div:has(p.highlight) {
        border: 2px solid green;
        padding: 10px;
    }

    p.highlight {
        color: red;
    }
</style>

<div>
    <p>This paragraph is normal.</p>
</div>

<div>
    <p class="highlight">This paragraph is highlighted.</p>
</div>

Only the second <div> will get the green border because it contains a <p> with the .highlight class.

How :has() Works

Example 1: Highlight a Section If It Contains a Focused Input

Imagine a form with multiple input fields inside different sections. You want to highlight the section when an input inside it receives focus.

section:has(input:focus) {
    border: 2px solid blue;
    background-color: #f0f8ff;
}

This CSS selects any <section> element that contains an <input> currently focused, highlighting that entire section.

Example 2: Style a List Item That Contains a Checkbox That's Checked

You can style parent elements based on child states, useful for custom UI effects.

li:has(input[type="checkbox"]:checked) {
    background-color: #d4edda;
    font-weight: bold;
}

This styles any <li> that contains a checked checkbox.

Example 3: Show a Warning if a Form Has an Invalid Input

Highlight the form itself if it contains invalid inputs:

form:has(input:invalid) {
    border: 2px solid red;
}

Practical & Useful Examples of :has()

1. Highlight a Menu Item When Its Dropdown Is Open

If you have a navigation menu where hovering or focusing on a submenu should highlight the parent menu item:

nav li:has(ul:hover),
nav li:has(ul:focus-within) {
    background-color: #eee;
    font-weight: bold;
}

Why useful?
You don't need JS to highlight the parent <li> when the submenu <ul> is hovered or focused.

2. Style a Card Differently If It Contains an Image

If you want cards with images to have a different background than those without:

.card:has(img) {
    background-color: #f9f9f9;
    border: 1px solid #ddd;
}

Cards without images stay default; cards with images get a subtle background and border.

3. Disable a Submit Button If a Form Contains Invalid Inputs

You can visually disable or style the submit button based on whether the form contains invalid inputs:

form:has(input:invalid) button[type="submit"] {
    background-color: #ccc;
    cursor: not-allowed;
    pointer-events: none;
}

This gives instant visual feedback without JS!

4. Style a Parent Container If It Contains a Checked Checkbox

Checkboxes inside a list that, when checked, style the entire list item:

li:has(input[type="checkbox"]:checked) {
    background-color: #d1e7dd;
    text-decoration: line-through;
    color: #495057;
}

Great for todo lists, task managers, etc.

5. Show a Warning Message Only If a Form Has Required Empty Fields

If you have a warning element inside a form, show it only when required inputs are empty:

form:has(input:required:invalid) .warning-message {
    display: block;
    color: red;
}

.warning-message {
    display: none;
}

This CSS-only validation UI enhancement reduces reliance on JS.

6. Expand an Accordion Section When It Contains a Focused Element

If an accordion expands when any of its children (like inputs or buttons) gain focus:

.accordion-item {
    overflow: hidden;
}

.accordion-item .accordion-content {
    max-height: 0;
    transition: max-height 0.3s ease;
}

.accordion-item:has(:focus-within) .accordion-content {
    max-height: 500px; /* expand */
}

Note: This assumes your accordion content has a reasonable maximum height. For truly dynamic content, consider using max-height: none with a transition on height instead.

7. Prevent Body Scroll When Sidebar Is Open

Control the entire page's overflow behavior based on whether a sidebar or modal is currently open:

body:has(.sidebar-open) {
    overflow: hidden;
}

Why useful?
When you have a mobile sidebar or modal overlay, you typically want to prevent the background page from scrolling. Instead of toggling classes with JavaScript on the body element, you can simply add/remove the .sidebar-open class on the sidebar itself, and CSS handles the rest.

HTML structure:

<body>
    <div class="sidebar" id="mySidebar">
        <!-- sidebar content -->
    </div>
    <main>
        <!-- page content -->
    </main>
</body>

JavaScript (minimal):

// Just toggle the class on the sidebar
document.getElementById("mySidebar").classList.toggle("sidebar-open")
// CSS automatically handles body overflow

This approach keeps your JavaScript focused on state management while letting CSS handle the styling consequences.

8. Dark Mode Toggle Without JavaScript State Management

Let a single checkbox control your entire page's theme without complex JavaScript state management:

body:has(.theme-toggle:checked) {
    background-color: #1a1a1a;
    color: #ffffff;
}

body:has(.theme-toggle:checked) .card {
    background-color: #2d2d2d;
    border-color: #444;
}

body:has(.theme-toggle:checked) .btn {
    background-color: #495057;
    border-color: #6c757d;
}

Why useful?
Traditional dark mode implementations require JavaScript to toggle classes on the body or root element. With :has(), you just need a checkbox anywhere in your DOM, and CSS handles all the theme switching logic declaratively.

9. E-commerce Product Cards with Dynamic States

Style product cards differently based on their current state (sale, out of stock, etc.):

.product-card:has(.sale-price) .original-price {
    text-decoration: line-through;
    color: #999;
    font-size: 0.9em;
}

.product-card:has(.sale-price) {
    border: 2px solid #dc3545;
    position: relative;
}

.product-card:has(.sale-price)::before {
    content: "SALE";
    position: absolute;
    top: 10px;
    right: 10px;
    background: #dc3545;
    color: white;
    padding: 2px 8px;
    font-size: 0.8em;
    border-radius: 3px;
}

.product-card:has(.out-of-stock) {
    opacity: 0.6;
    pointer-events: none;
}

Why useful?
No need to manually add modifier classes like .product-card--on-sale. Just add the sale price or stock status elements, and CSS automatically styles the entire card accordingly.

10. Form Step Indicators with Validation States

Create visual step indicators that automatically update based on form validation:

.form-step:has(input:invalid) .step-indicator {
    background-color: #dc3545;
    color: white;
}

.form-step:has(input:valid) .step-indicator {
    background-color: #28a745;
    color: white;
}

.form-step:has(input:focus) .step-indicator {
    border: 2px solid #007bff;
    box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
}

.form-step:has(input:placeholder-shown) .step-indicator {
    background-color: #6c757d;
}

Why useful?
Perfect for multi-step forms or wizards. The step indicators automatically reflect the current validation state without needing JavaScript to manually update their appearance.

11. Media Gallery with Selection Mode

Reveal batch actions and visual feedback when media items are selected:

.gallery:has(.media-item.selected) .batch-actions {
    display: flex;
    position: sticky;
    bottom: 20px;
    background: #007bff;
    color: white;
    padding: 1rem;
    border-radius: 8px;
    margin: 1rem;
}

.gallery:has(.media-item.selected) .media-item:not(.selected) {
    opacity: 0.5;
    transform: scale(0.95);
}

.batch-actions {
    display: none;
    transition: all 0.3s ease;
}

Why useful?
Common pattern in admin interfaces, photo galleries, or file managers. Once any item is selected, the interface automatically switches to "selection mode" with batch actions and visual feedback.

12. Comment System with Interactive States

Style comment threads based on their current interaction state:

.comment:has(.reply-form:not([hidden])) {
    border-left: 3px solid #007bff;
    background-color: #f8f9fa;
    padding-left: 1rem;
}

.comment:has(.replies) {
    margin-bottom: 1.5rem;
}

.comment:has(.replies)::after {
    content: "";
    display: block;
    width: 2px;
    background: #dee2e6;
    position: absolute;
    left: 20px;
    height: 100%;
}

Why useful?
Automatically style comments when reply forms are open or when they contain reply threads, creating a more dynamic and responsive comment interface.

13. Navigation with Notification Badges

Show notification indicators only when there are actual notifications:

.nav-item:has(.notification-count:not(:empty)) .nav-link::after {
    content: "";
    position: absolute;
    top: 0;
    right: 0;
    width: 8px;
    height: 8px;
    background-color: #dc3545;
    border-radius: 50%;
}

.nav-item:has(.notification-count:not(:empty)) .nav-link {
    position: relative;
}

/* Show actual count for numbers */
.nav-item:has(.notification-count[data-count]) .nav-link::after {
    content: attr(data-count);
    width: auto;
    height: auto;
    padding: 2px 6px;
    font-size: 0.7em;
    line-height: 1;
    min-width: 16px;
    text-align: center;
}

Why useful?
Clean, semantic approach to notification badges. The badge only appears when there's actual content, and you can even display the count using data attributes.

Bonus: Nested Example for Complex UI Logic

Highlight a comment thread if any reply inside is authored by the current user:

.comment-thread:has(.reply.author-current-user) {
    border-left: 4px solid #007bff;
    background-color: #e7f1ff;
}

Use Cases & Benefits

  • Parent selection without JavaScript: No more need for JS hacks or libraries just to style parents based on children.
  • Better form styling: Style form groups or error containers dynamically.
  • Conditional UI states: Trigger complex UI changes based on child elements.
  • Cleaner code: Reduce reliance on JS for simple conditional styling.

Browser Support

The :has() pseudo-class was standardized in Selectors Level 4 and is supported by most modern browsers:

Browser Support
Chrome Supported (from v105)
Edge Supported (from v105)
Firefox Supported (from v121)
Safari Supported (from v15.4)
Opera Supported (from v91)
Internet Explorer Not supported

Note: Always check up-to-date compatibility on Can I Use before using in production.

Performance Considerations

Because :has() lets you select parents based on child selectors, browsers need to do more complex matching. This can have performance implications if used on very large DOM trees or with very complex selectors.

What counts as "complex selectors":

  • Deep descendant selectors like :has(.level1 .level2 .level3 .target)
  • Multiple pseudo-classes like :has(input:focus:valid:required)
  • Universal selectors like :has(*:hover)
  • Combining :has() with other expensive selectors

Performance tips:

  • Use :has() selectively and avoid overly broad selectors
  • Prefer child selectors (>) over descendant selectors when possible
  • Avoid using it inside animations or frequently triggered styles
  • Test performance if applying on large or complex pages
  • Consider scoping your :has() selectors to specific containers rather than applying globally

Gotchas and Limitations

  • Browser support: Not supported on IE or older browsers, fallback or polyfills may be needed
  • Performance impact: Complex selectors inside :has() may reduce performance on large DOMs
  • Sibling relationships: You can select based on sibling state using combinators like :has(+ .next-sibling) or :has(~ .any-following-sibling)
  • Specificity: :has() itself has no specificity, but the selectors inside it count toward specificity calculations
  • Cannot nest :has(): You cannot use :has() inside another :has() selector

Summary

The CSS :has() pseudo-class selector is a powerful new tool that finally unlocks parent selection based on children, something developers have long needed. It simplifies conditional styling, reduces dependency on JavaScript, and opens doors for cleaner, more maintainable CSS.

While browser support is solid and growing, always check compatibility and performance before using it in production. When used thoughtfully, :has() can eliminate many JavaScript-based styling solutions and make your CSS more declarative and easier to maintain.