The Parent Selector Arrives: Practical CSS :has() Patterns for Production

CSS has always had a peculiar limitation: it could only look downward. You could style a child based on its parent, but never the reverse. For over two decades, web developers worked around this with JavaScript, extra classes, or structural hacks. The :has() pseudo-class changes that — and by now, it has enough browser support that you can use it in production without fallbacks.

Basics of :has() are simple: it’s a functional pseudo-class that matches an element if any of the relative selectors inside its parentheses match. Think of it as a parent selector, a previous-sibling selector, and a conditional state checker — all in one.

Every major browser now supports :has(): Chrome since version 105, Safari since 15.4, and Firefox since version 121. If you’re still adding JavaScript to toggle classes based on DOM structure, this post walks through the patterns you can replace today.

Selecting Parents Based on Their Children

The most common use case: styling a container based on what’s inside it. Before :has(), you’d add a modifier class via JavaScript or restructure your HTML. Now, CSS handles it directly:

/* Highlight a card that contains an image */
.card:has(img) {
  padding: 0;
}

/* Style a form section differently when it has an error */
.form-group:has(.error-message) {
  border-color: #e53e3e;
  background-color: #fff5f5;
}

/* Hide the entire section if it has no content */
section:has(> *:not(h2):not(:empty)) {
  display: none;
}

The :has(img) selector matches any .card element that contains an img descendant. The > combinator inside :has() constrains the match to direct children, giving you precise control over what triggers the styling.

Form Validation States Without JavaScript

One of the most practical applications is form validation feedback. You can style a label or container based on whether its input is valid or invalid, entirely in CSS:

/* Show a green checkmark container when the input is valid */
.input-wrapper:has(input:valid) .status-icon {
  background-image: url('check.svg');
}

.input-wrapper:has(input:valid) {
  border-color: #38a169;
}

/* Show error styling when the input is invalid and has been touched */
.input-wrapper:has(input:invalid:focus) {
  border-color: #e53e3e;
}

.input-wrapper:has(input:invalid:focus) .error-text {
  display: block;
}

Combine this with HTML5 input attributes like pattern, required, and minlength, and you get real-time validation feedback without a single line of JavaScript. The :focus inside :has() ensures the error state only appears after the user interacts with the field — not on initial page load.

Conditional Layouts with CSS Grid and Flexbox

:has() shines when you need to adjust layouts based on content count. A common pattern: a grid that switches from horizontal to vertical when it has only one item:

.gallery {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1rem;
}

/* Single item: center it, full width */
.gallery:has(> figure:only-child) {
  grid-template-columns: 1fr;
  max-width: 600px;
  margin: 0 auto;
}

/* Two items: side by side, equal width */
.gallery:has(> figure:nth-child(2):last-child) {
  grid-template-columns: repeat(2, 1fr);
}

The :only-child selector inside :has() checks that the gallery contains exactly one item. The :nth-child(2):last-child combination checks for exactly two items. Both are relative selectors that :has() evaluates against the gallery element itself.

Sub-Navigation Menus Without Extra Markup

Drop-down menus often need an indicator — a chevron icon — only on items that have sub-menus. Before :has(), you’d add a class or inject the icon via JavaScript. Now you can detect the presence of a nested list directly:

.nav-item:has(> .sub-menu) > a::after {
  content: '▾';
  margin-left: 0.5em;
  font-size: 0.8em;
  opacity: 0.7;
}

/* Expand the sub-menu on hover */
.nav-item:has(> .sub-menu):hover > .sub-menu {
  display: block;
  opacity: 1;
  transform: translateY(0);
}

.sub-menu {
  display: none;
  opacity: 0;
  transform: translateY(-10px);
  transition: all 0.2s ease;
}

This keeps your HTML clean — no extra classes, no data attributes, no JavaScript. The CSS itself detects the structural relationship and applies the right styling.

Previous-Sibling Selection

CSS combinators only go forward — you can select elements that come after a specific element, but never before. :has() inverts this by checking the parent’s children in reverse. If you need to style a heading that precedes a code block differently from one that precedes a paragraph:

/* Add extra margin below a heading that precedes a code block */
h2:has(+ pre) {
  margin-bottom: 0.5rem;
  font-family: monospace;
  letter-spacing: -0.02em;
}

/* Add a separator before a section that follows an image */
figure:has(+ section) {
  margin-bottom: 3rem;
  padding-bottom: 2rem;
  border-bottom: 1px solid #e2e8f0;
}

The + adjacent sibling combinator inside :has() checks whether the next sibling matches. This effectively gives you previous-sibling selection — you’re styling the current element conditionally based on what follows it.

Performance Characteristics

:has() has gotten a reputation for being slow, largely because earlier proposals would have been. In practice, browser engines have optimized it well. The CSS specification intentionally limits :has() to prevent performance issues — it cannot be used inside another :has(), and it cannot be used inside pseudo-elements like ::before.

That said, deeply nested :has() selectors with descendant combinators can cause layout thrashing on very large DOM trees. Keep your selectors as specific as possible — prefer child combinators (>) over descendant combinators (space) inside :has(), and avoid chaining multiple :has() calls on the same element.

Progressive Enhancement Strategy

If you need to support older browsers, use :has() as an enhancement layer. The @supports rule detects it cleanly:

/* Base styles for all browsers */
.form-group {
  border: 2px solid #e2e8f0;
}

/* Enhancement: only in browsers that support :has() */
@supports selector(:has(*)) {
  .form-group:has(input:invalid:focus) {
    border-color: #e53e3e;
  }
}

The selector(:has(*)) feature query returns true only in browsers that understand :has(). This lets you layer the enhancement safely.

Wrapping Up

The :has() selector fills a gap that CSS has had since its inception. It replaces JavaScript-based class toggling for parent selection, conditional layouts, form validation states, and previous-sibling styling. With universal browser support since Firefox 121 (December 2023), there’s no reason to keep the old workarounds.

The patterns in this post — parent selection, form states, responsive grids, sub-menus, and previous-sibling styling — cover the majority of real-world cases. Start by auditing your JavaScript for DOM-structure-based class toggles. You’ll likely find several that :has() can eliminate, simplifying both your scripts and your markup. The official MDN documentation for :has() covers the full specification details and additional edge cases.

Leave a Reply

Your email address will not be published. Required fields are marked *