Skip to content
C Codeloom
HTML & CSS

CSS :has() Pseudo-Class Tutorial

Learn how the CSS :has() pseudo-class enables true parent selectors, conditional styling, and sibling-aware rules. Includes a mental model, examples, pitfalls, and best practices for production use.

·4 min read · By Codeloom
Intermediate 8 min read

What you'll learn

  • What :has() actually matches and why it is called the parent selector
  • A simple mental model for relational selectors
  • How to style a card based on what is inside it
  • Common pitfalls around specificity and performance
  • Best practices for shipping :has() to production

Prerequisites

  • Comfort with basic CSS selectors and combinators

What and Why

For two decades, CSS could only style elements based on what came before them in the tree. You could not style a parent based on its child, or an element based on a later sibling. The :has() pseudo-class changes that. It is a relational selector that matches an element if any selector inside it matches a descendant or sibling.

The why is everyday markup. A card that contains an image should look different from a card with only text. A form field with an error message should highlight its label. Before :has(), you reached for JavaScript or extra class names to express these rules. Now CSS can do it natively, in all evergreen browsers.

Mental Model

Read A:has(B) as: pick element A, but only if a B exists relative to it. The selector inside the parentheses is evaluated against the descendants of A by default. Combinators inside :has() shift the scope: :has(> B) means a direct child, :has(+ B) means a following sibling, :has(~ B) means any later sibling.

The matched element is always the outer one. The inner selector is only a condition, never the target.

Hands-on Example

Say you have an article card. You want to add a rich layout when the card contains an image, and a compact layout when it does not. You also want to flag any form field whose input is invalid.

<article class="card">
  <img src="cover.jpg" alt="" />
  <h2>With image</h2>
</article>

<article class="card">
  <h2>Text only</h2>
</article>

<label>
  Email
  <input type="email" required />
</label>
.card:has(img) {
  display: grid;
  grid-template-columns: 120px 1fr;
  gap: 1rem;
}

.card:not(:has(img)) {
  padding: 2rem;
  text-align: center;
}

label:has(input:invalid) {
  color: crimson;
}
.card:has(img)

article.card         <- selector matches THIS element
  |-- img            <- because this descendant matched
  \-- h2

.card:not(:has(img))

article.card         <- selector matches THIS element
  \-- h2            (no img descendant exists)
How :has() evaluates a card

You can also use :has() for sibling-aware layout. For example, shrink a sidebar only when a main panel follows it: .sidebar:has(+ .main).

Common Pitfalls

The first pitfall is treating :has() like a query selector. It returns a boolean, not the inner element. article:has(img) { border: red; } colors the article, not the image.

The second pitfall is specificity. :has() takes the highest specificity of the selectors inside it. .card:has(.badge.hot) is more specific than you might guess, which can trump later rules.

The third pitfall is nesting :has() inside :has(). The spec allows it, but readability falls off a cliff and so does intent. Extract a class or restructure your markup before nesting more than one level.

Finally, do not put extremely broad selectors inside :has() on large pages. body:has(*) is legal but pointless and forces the engine to consider every element.

Best Practices

Reach for :has() when the condition is genuinely structural and unlikely to be expressed as a class. Form validation states, conditional card layouts, and “contains-a-link” tweaks are perfect fits.

Keep the inner selector tight. Prefer :has(> img) over :has(img) when you mean a direct child. The scoped form is faster and clearer.

Combine :has() with :not() for clean either-or styling instead of writing two rule sets that fight each other.

Document the intent. A comment like /* card has cover image */ saves the next developer a round trip to the docs.

Wrap-up

:has() removes a long-standing limitation of CSS by letting selectors look forward and down. Treat it as a relational filter on the outer element, keep inner selectors small, and watch specificity. With those habits, you can delete a surprising amount of layout JavaScript and let the cascade do its job.