CSS :has() Selector: Complete Guide — The Parent Selector

Published on

For over two decades, CSS developers asked for one feature above all others: a parent selector. The ability to style an element based on its children was considered the holy grail of CSS. In 2022, browsers began shipping the :has() pseudo-class, and by late 2023 it reached full cross-browser support. This guide covers everything you need to know to use :has() effectively in production.

1. What Is :has()?

The :has() pseudo-class is a relational pseudo-class defined in the CSS Selectors Level 4 specification. It matches an element if any of the relative selectors passed as arguments match at least one element when anchored against the subject element.

In plain language: :has() lets you select an element based on what it contains or what follows it. This is why it is widely known as the "parent selector," though it can do much more than parent selection alone.

/* Select any <a> that contains an <img> */
a:has(img) {
  display: inline-block;
  border: 2px solid transparent;
}

/* Select any <section> that contains a <h2> */
section:has(h2) {
  border-left: 4px solid #3b82f6;
  padding-left: 1rem;
}

Before :has(), achieving this required JavaScript. You would query the DOM, check for children, and toggle classes manually. Now the browser handles it natively, reacting to DOM changes in real time.

2. Browser Support

The :has() selector is Baseline Widely Available as of 2025. Here is the support timeline:

For progressive enhancement, use @supports:

/* Feature detection for :has() */
@supports selector(:has(*)) {
  .card:has(img) {
    grid-template-rows: auto 1fr;
  }
}

/* Fallback for older browsers */
@supports not selector(:has(*)) {
  .card-with-image {
    grid-template-rows: auto 1fr;
  }
}

Polyfills are impractical for :has() because the selector needs live DOM awareness. If you must support very old browsers, use JavaScript class toggling as a fallback rather than attempting a CSS polyfill.

3. Basic Syntax

The :has() pseudo-class accepts one or more comma-separated relative selectors as its argument:

/* Single argument */
element:has(selector) { }

/* Multiple arguments (OR logic) */
element:has(selector1, selector2) { }

/* Direct child only */
element:has(> selector) { }

/* Descendant at any depth (default) */
element:has(selector) { }

The comma inside :has() acts as logical OR. The element matches if any of the selectors match:

/* Matches if the section contains h2 OR h3 */
section:has(h2, h3) {
  margin-bottom: 2rem;
}

/* Equivalent to writing: */
section:has(h2),
section:has(h3) {
  margin-bottom: 2rem;
}

Use the direct child combinator > when you only want to match immediate children, not deeply nested elements:

/* Only matches if img is a direct child */
.card:has(> img) {
  padding-top: 0;
}

/* Matches img at any depth inside .card */
.card:has(img) {
  padding-top: 0;
}

4. Parent Selection Patterns

The most common use of :has() is styling a parent based on the state or type of its children.

Style parent based on child element type

/* Card with an image gets a different layout */
.card:has(> img) {
  display: grid;
  grid-template-rows: 200px 1fr;
  padding: 0;
}

.card:not(:has(> img)) {
  padding: 1.5rem;
}

Style parent based on child state

/* Highlight form group when its input is focused */
.form-group:has(input:focus) {
  background: rgba(59, 130, 246, 0.05);
  border-color: #3b82f6;
}

/* Style a list item when its link is hovered */
li:has(a:hover) {
  background: rgba(255, 255, 255, 0.05);
}

Style parent based on child count

<!-- HTML -->
<ul class="tag-list">
  <li>CSS</li>
  <li>HTML</li>
  <li>JavaScript</li>
</ul>
/* If the list has more than 3 items, switch to grid */
.tag-list:has(> :nth-child(4)) {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
}

5. Sibling Selection with :has()

CSS has always been able to select next siblings with + and ~, but never previous siblings. The :has() selector changes that.

Previous sibling selector

/* Select the h2 that comes BEFORE a p */
h2:has(+ p) {
  margin-bottom: 0.5rem;
}

/* Select h2 that does NOT have a p after it */
h2:not(:has(+ p)) {
  margin-bottom: 1.5rem;
}

/* Select all items before a hovered item */
.item:has(~ .item:hover) {
  opacity: 0.7;
}

Star rating pattern

A classic example: hovering a star highlights it and all stars before it.

<div class="stars">
  <span class="star">&#9733;</span>
  <span class="star">&#9733;</span>
  <span class="star">&#9733;</span>
  <span class="star">&#9733;</span>
  <span class="star">&#9733;</span>
</div>
.star {
  color: #666;
  cursor: pointer;
  font-size: 1.5rem;
  transition: color 0.15s;
}

/* Hovered star + all previous stars turn gold */
.star:hover,
.star:has(~ .star:hover) {
  color: #f59e0b;
}

6. Combining :has() with Other Selectors

:has() + :not()

/* Select cards WITHOUT images */
.card:not(:has(img)) {
  min-height: 200px;
  display: flex;
  align-items: center;
}

/* Select inputs that are NOT inside a :has(:invalid) form */
form:not(:has(:invalid)) .submit-btn {
  opacity: 1;
  pointer-events: auto;
}

:has() + :is()

/* Match sections containing any heading level */
section:has(:is(h1, h2, h3, h4, h5, h6)) {
  padding-top: 2rem;
  border-top: 1px solid rgba(255, 255, 255, 0.1);
}

/* Style a container that has any interactive element */
.container:has(:is(input, select, textarea, button)) {
  padding: 1.5rem;
  background: rgba(255, 255, 255, 0.02);
}

:has() + :where()

Use :where() inside :has() when you want zero specificity contribution from the argument list:

/* The :where() keeps specificity low */
.wrapper:has(:where(.alert, .warning, .error)) {
  border-left: 4px solid #ef4444;
}

:has() with CSS Nesting

.card {
  padding: 1.5rem;
  border: 1px solid rgba(255, 255, 255, 0.1);

  &:has(img) {
    padding: 0;

    & img {
      border-radius: 8px 8px 0 0;
      width: 100%;
    }

    & .card-body {
      padding: 1.5rem;
    }
  }
}

Learn more about nesting syntax in our CSS Nesting Complete Guide.

7. Form Styling with :has()

Form styling is where :has() truly shines. It replaces many patterns that previously needed JavaScript.

Validation states

/* Green border when all required fields are valid */
form:has(input:required:valid):not(:has(input:required:invalid)) {
  border-color: #22c55e;
}

/* Style individual field wrappers */
.field:has(input:invalid:not(:placeholder-shown)) {
  --field-color: #ef4444;
}

.field:has(input:valid:not(:placeholder-shown)) {
  --field-color: #22c55e;
}

.field {
  border-left: 3px solid var(--field-color, transparent);
}

Checkbox and radio styling

/* Style a label's parent when checkbox is checked */
.option:has(input[type="checkbox"]:checked) {
  background: rgba(59, 130, 246, 0.1);
  border-color: #3b82f6;
}

/* Toggle visibility based on radio selection */
.panel:has(input[value="advanced"]:checked) ~ .advanced-options {
  display: block;
}

:has() vs :focus-within

The :focus-within pseudo-class only responds to focus. With :has(), you can respond to any state:

/* :focus-within equivalent */
.search-bar:has(input:focus) {
  box-shadow: 0 0 0 2px #3b82f6;
}

/* But :has() can also do things :focus-within cannot */
.search-bar:has(input:not(:placeholder-shown)) {
  /* Input has content — show clear button */
  .clear-btn { display: block; }
}

.search-bar:has(input:placeholder-shown) {
  /* Input is empty — show search icon */
  .search-icon { opacity: 1; }
}

8. Layout Patterns with :has()

Quantity queries

Adjust layout based on how many items a container holds:

/* 1 item: single column */
.grid:has(> :only-child) {
  grid-template-columns: 1fr;
  max-width: 600px;
}

/* 2 items: two columns */
.grid:has(> :nth-child(2)):not(:has(> :nth-child(3))) {
  grid-template-columns: 1fr 1fr;
}

/* 3+ items: three columns */
.grid:has(> :nth-child(3)) {
  grid-template-columns: repeat(3, 1fr);
}

Empty state styling

/* Show empty state message when list has no items */
.task-list:not(:has(> .task)) {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 200px;
}

.task-list:not(:has(> .task))::after {
  content: "No tasks yet. Add one to get started.";
  color: #9ca3af;
  font-style: italic;
}

For more layout techniques, see our CSS Grid Complete Guide.

Sidebar layout toggle

/* If sidebar has content, use two-column layout */
.page:has(> .sidebar:not(:empty)) {
  display: grid;
  grid-template-columns: 1fr 300px;
  gap: 2rem;
}

/* If sidebar is empty, full-width main content */
.page:has(> .sidebar:empty),
.page:not(:has(> .sidebar)) {
  display: block;
  max-width: 800px;
}

9. Theming with :has()

The :has() selector enables CSS-only theme switching without JavaScript.

Dark mode toggle

<body>
  <label class="theme-toggle">
    <input type="checkbox" id="dark-mode">
    Dark Mode
  </label>
  <!-- page content -->
</body>
/* Default: light theme */
:root {
  --bg: #ffffff;
  --text: #1a1a2e;
  --surface: #f4f4f5;
}

/* Switch to dark when checkbox is checked */
:root:has(#dark-mode:checked) {
  --bg: #0a0a0b;
  --text: #e4e4e7;
  --surface: #18181b;
}

body {
  background: var(--bg);
  color: var(--text);
}

Combine with CSS custom properties for a full theming system. You can also pair this with prefers-color-scheme for automatic defaults:

/* Respect system preference by default */
@media (prefers-color-scheme: dark) {
  :root:not(:has(#light-mode:checked)) {
    --bg: #0a0a0b;
    --text: #e4e4e7;
  }
}

/* Override with explicit toggle */
:root:has(#light-mode:checked) {
  --bg: #ffffff;
  --text: #1a1a2e;
}

Accent color themes

:root:has(input[name="theme"][value="blue"]:checked) {
  --accent: #3b82f6;
}
:root:has(input[name="theme"][value="green"]:checked) {
  --accent: #22c55e;
}
:root:has(input[name="theme"][value="purple"]:checked) {
  --accent: #a855f7;
}

Active parent menu items

/* Highlight nav item that contains the current page link */
.nav-item:has(> a[aria-current="page"]) {
  background: rgba(59, 130, 246, 0.1);
  border-bottom: 2px solid #3b82f6;
}

/* Style parent menu when submenu is open */
.nav-item:has(> .submenu:hover),
.nav-item:has(> a:hover) {
  background: rgba(255, 255, 255, 0.05);
}

Dropdown indicator

/* Add dropdown arrow only to items with submenus */
.nav-item:has(> .submenu)::after {
  content: "\25BE"; /* down triangle */
  margin-left: 0.25rem;
  font-size: 0.8em;
}

/* Show submenu on hover */
.nav-item:has(> .submenu):hover > .submenu {
  display: block;
  opacity: 1;
}

Breadcrumb separator

/* Only add separator if there is a next sibling */
.breadcrumb-item:has(+ .breadcrumb-item)::after {
  content: "/";
  margin: 0 0.5rem;
  color: #9ca3af;
}

11. Performance Considerations

Browsers have invested heavily in optimizing :has(). Chrome, for example, uses bloom filters and fast-reject heuristics to avoid unnecessary style recalculations. Still, some patterns are more expensive than others.

Forgiving selector list

The :has() pseudo-class uses a forgiving selector list. If one argument is invalid, the others still work:

/* The :foo part is invalid but doesn't break the rule */
.card:has(img, :foo, .badge) {
  /* Still matches cards with img or .badge */
}

Performance tips

/* Slower: broad subject, descendant search */
div:has(.active) { }

/* Faster: specific subject, direct child */
.tab-bar:has(> .tab.active) { }

12. Replacing JavaScript with :has()

Many common JavaScript patterns exist solely because CSS could not select parents. Here are patterns you can now handle with CSS alone.

Conditional class toggling

// Before: JavaScript
document.querySelectorAll('.card').forEach(card => {
  if (card.querySelector('img')) {
    card.classList.add('has-image');
  }
});
/* After: CSS only */
.card:has(img) {
  /* styles for cards with images */
}

Form submit button state

// Before: JavaScript
form.addEventListener('input', () => {
  const allValid = form.checkValidity();
  submitBtn.disabled = !allValid;
});
/* After: CSS only (visual treatment) */
form:has(:invalid) .submit-btn {
  opacity: 0.5;
  pointer-events: none;
}

form:not(:has(:invalid)) .submit-btn {
  opacity: 1;
  pointer-events: auto;
}

Note: The CSS approach handles visual state. You may still want JavaScript for actual form validation and submission logic.

Empty state detection

// Before: JavaScript
if (container.children.length === 0) {
  container.classList.add('is-empty');
}
/* After: CSS only */
.container:not(:has(> *)) {
  /* empty state styles */
}
.container:not(:has(> *))::before {
  content: "Nothing here yet.";
}

Tab-panel connection

<div class="tabs">
  <input type="radio" name="tab" id="tab1" checked>
  <label for="tab1">Tab 1</label>
  <input type="radio" name="tab" id="tab2">
  <label for="tab2">Tab 2</label>
  <div class="panel" data-tab="1">Panel 1 content</div>
  <div class="panel" data-tab="2">Panel 2 content</div>
</div>
.panel { display: none; }

.tabs:has(#tab1:checked) [data-tab="1"],
.tabs:has(#tab2:checked) [data-tab="2"] {
  display: block;
}

13. Best Practices

  1. Use :has() for styling, not logic. It is a CSS selector, not a programming construct. Keep validation, data handling, and state management in JavaScript.
  2. Prefer direct child selectors. :has(> .child) is both more performant and more predictable than :has(.child).
  3. Combine with @supports for progressive enhancement. Even though browser support is excellent, provide fallbacks for critical UI patterns.
  4. Avoid nesting :has() inside :has(). The spec explicitly disallows :has() within :has(). It will not match.
  5. Keep selectors readable. If a :has() selector becomes longer than a single line, consider using custom properties or adding a comment explaining the intent.
  6. Test dynamically. The :has() selector responds to DOM changes in real time. Add and remove elements in DevTools to verify behavior.
  7. Use our CSS Beautifier to keep complex selectors formatted and readable.
  8. Pair with CSS custom properties for maximum flexibility. Use :has() to set variables and let children inherit them.
/* Best practice: :has() sets variables, children consume them */
.card:has(> .badge[data-type="warning"]) {
  --card-accent: #f59e0b;
  --card-bg: rgba(245, 158, 11, 0.05);
}

.card:has(> .badge[data-type="error"]) {
  --card-accent: #ef4444;
  --card-bg: rgba(239, 68, 68, 0.05);
}

.card {
  background: var(--card-bg, transparent);
  border-left: 3px solid var(--card-accent, transparent);
}

For more CSS selector techniques, explore our CSS Selectors Complete Guide and CSS Animations Complete Guide.

Frequently Asked Questions

What is the CSS :has() selector and why is it called the parent selector?

The CSS :has() selector is a relational pseudo-class that selects an element based on its descendants, children, or siblings. It is called the "parent selector" because it allows you to style a parent element based on what it contains. For example, a:has(img) selects any anchor element that contains an image. Before :has(), CSS could only select elements based on their ancestors, never the other way around. The :has() selector finally gives CSS the ability to look downward in the DOM tree.

Is the CSS :has() selector supported in all browsers?

Yes, as of late 2023 the CSS :has() selector is supported in all major browsers including Chrome 105+, Safari 15.4+, Edge 105+, and Firefox 121+. It is considered Baseline Widely Available in 2025. For older browsers, you can use @supports selector(:has(*)) to provide fallback styles, but polyfills are generally not practical because :has() requires real-time DOM awareness that JavaScript shims cannot efficiently replicate.

Can :has() select previous siblings in CSS?

Yes, :has() can effectively select previous siblings by combining it with the general sibling combinator (~) or adjacent sibling combinator (+). For example, h2:has(+ p) selects an h2 that is immediately followed by a p element, effectively acting as a previous sibling selector. Similarly, .item:has(~ .item:hover) selects all .item elements that appear before a hovered .item. This was impossible in CSS before :has().

Does using :has() cause CSS performance problems?

In typical usage, :has() does not cause noticeable performance issues. Browsers have implemented specific optimizations for :has() selectors. However, deeply nested or highly complex :has() selectors with broad universal matches like :has(*) on the body element can trigger more style recalculations. Best practices include keeping :has() selectors as specific as possible, avoiding :has() in selectors that match thousands of elements, and preferring direct child selectors (:has(> .child)) over descendant selectors when appropriate.

How does :has() compare to :focus-within and other existing pseudo-classes?

:focus-within is a narrow pseudo-class that only matches when a descendant has focus. :has() is far more general and can replicate :focus-within with :has(:focus), but also handles cases :focus-within cannot, such as styling based on checked checkboxes, valid/invalid inputs, hover states of specific children, empty containers, or the presence of certain child element types. Think of :has() as a superset that can express what :focus-within does and much more.

Related Resources

CSS Grid Complete Guide
Master two-dimensional layouts with CSS Grid
CSS Selectors Complete Guide
Every CSS selector explained with examples
CSS Animations Complete Guide
Transitions, keyframes, and animation best practices
CSS Variables Guide
Custom properties, theming, and dynamic styles
CSS Beautifier Tool
Format and clean up your CSS code