What is the CSS :has() pseudo-class?

The CSS :has() pseudo-class is a relational selector. In beginner-friendly terms: it lets you select an element based on what’s inside it (or sometimes what’s next to it).

The “wow” moment is this: you can style a parent based on its children. Historically, CSS could easily style children based on a parent, but not the other way around (without JavaScript).

Think of :has() as CSS finally getting a built-in “if” statement for structure: “Select this element if it has that children or descendant”.

The core syntax of :has()

You write it like this:

  • .parent:has(.child) means “select .parent if it contains an element matching .child”.
  • .parent:has(> .child) means “select .parent if it has a direct child matching .child”.
  • .item:has(+ .item) means “select .item if it has an adjacent sibling matching .item”.

The selector inside :has(...) is a relative selector, meaning it’s evaluated “from” the element you’re attaching :has() to.

Learn more about the direct child selector in the CSS Direct Child Selector (>) Interactive Tutorial, and learn more about the sibling selector in the CSS Sibling Selector Interactive Tutorial.

A simple mental model that keeps you sane

Read :has() left-to-right:

  • Pick a candidate element (the part before :has())
  • Check if it contains / relates to the selector inside :has(...)
  • If yes, apply the styles

A helpful way to say it out loud: “Style this thing if it has that thing.”

Basic parent styling with :has()

Let’s start with a classic example: cards. Some cards have an image, some don’t. With :has(), we can automatically change the layout and styling when an image exists.

.card:has(img) {
  display: grid;
  grid-template-columns: 120px 1fr;
  gap: 12px;
  align-items: center;
}
  
.card:has(img) {
  border-left: 10px solid #111;
  padding-left: 12px;
}
  
.card:not(:has(img)) {
  background: #f2f2f2;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.cards {
  display: grid;
  gap: 12px;
  max-width: 760px;
  margin: 0 auto;
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
}

.card {
  border: 2px solid #111;
  border-radius: 12px;
  padding: 14px;
  background: #fff;
}

.card h4 {
  margin: 0 0 6px 0;
  font-size: 18px;
}

.card p {
  margin: 0;
  line-height: 1.4;
}

.card img {
  width: 120px;
  height: 90px;
  object-fit: cover;
  border-radius: 10px;
  display: block;
  border: 2px solid #111;
}
  
Random

Card with an image

This card automatically switches to a two-column layout.

Card without an image

No image? Different styling. No extra classes needed.

Random

Another image card

Same HTML structure, CSS decides the layout.

Direct child vs descendant inside :has()

The difference between :has(.thing) and :has(> .thing) matters. If you only want to match when something is a direct child, use >.

.panel:has(> .badge) {
  border-style: dashed;
}
  
.panel:has(.badge) {
  background: #fff6d6;
}
  
.panel:has(> .content > .badge) {
  outline: 4px solid #111;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrap {
  max-width: 760px;
  margin: 0 auto;
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  display: grid;
  gap: 12px;
}

.panel {
  border: 2px solid #111;
  border-radius: 12px;
  padding: 14px;
  background: #fff;
}

.badge {
  display: inline-block;
  padding: 6px 10px;
  border: 2px solid #111;
  border-radius: 999px;
  font-size: 12px;
  background: #e8f7ff;
}

.content {
  margin-top: 10px;
  padding: 10px;
  border: 2px solid #111;
  border-radius: 10px;
  background: #f2f2f2;
}
  
Direct child badge

This panel has a badge as a direct child.

Nested badge

This badge is not a direct child of .panel.

No badge here. Just vibes.

In that third example, notice how we are checking to see if .panel has a direct child with a class content, which itself has a direct child with a class badge.

CSS :has() for siblings and layout switches

:has() can also look at what’s next to an element using sibling combinators: + (adjacent sibling) and ~ (general sibling).

This is super useful for “layout switches” where you want an element to look different if it’s followed by something.

h3:has(+ p) {
  margin-bottom: 6px;
}
  
h3:has(+ ul) {
  border-bottom: 3px solid #111;
  padding-bottom: 8px;
}
  
h3:has(+ .note) {
  background: #e8f7ff;
  padding: 10px 12px;
  border-radius: 10px;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.article {
  max-width: 760px;
  margin: 0 auto;
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  line-height: 1.5;
}

.article h3 {
  margin: 18px 0 12px 0;
}

.article p,
.article ul {
  margin: 0 0 14px 0;
}

.article ul {
  padding-left: 18px;
}

.note {
  border: 2px solid #111;
  border-radius: 10px;
  padding: 12px;
  background: #fff;
}
  

Heading followed by a paragraph

This heading gets a tighter bottom margin because it has an adjacent p.

Heading followed by a list

  • Because a list is coming next...
  • ...we add a border line for visual grouping.

Heading followed by a note

This heading gets a highlighted background because it’s immediately followed by .note.

CSS :has() for form states (without JavaScript)

Forms are where :has() feels like cheating (the legal kind). You can style a wrapper when an input inside it is focused, invalid, checked, etc.

This means cleaner HTML: you don’t need to add “state classes” like .is-focused with JavaScript.

.field:has(input:focus) {
  outline: 4px solid #111;
}
  
.field:has(input:invalid) .hint {
  display: block;
}
  
.field:has(input:valid) {
  background: #999;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.form {
  max-width: 760px;
  margin: 0 auto;
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  display: grid;
  gap: 12px;
}

.field {
  border: 2px solid #111;
  border-radius: 12px;
  padding: 12px;
  background: #fff;
}

.field label {
  display: block;
  font-weight: 700;
  margin-bottom: 8px;
}

.field input {
  width: 100%;
  padding: 10px 12px;
  border: 2px solid #111;
  border-radius: 10px;
  font-size: 16px;
}

.hint {
  margin-top: 10px;
  border: 2px solid #111;
  border-radius: 10px;
  padding: 10px;
  background: #fff6d6;
  display: none;
}
  
This shows up when the input is :invalid.
This shows up when the input is :invalid.

CSS :has() with checkboxes to “toggle” panels

You can style a container when a checkbox inside it is checked. This can create “accordion-ish” UI patterns with zero JavaScript.

.row:has(input:checked) .panel {
  max-height: 160px;
  opacity: 1;
  transform: translateY(0);
}
  
.row:has(input:checked) {
  background: #e8f7ff;
}
  
.row:has(input:checked) .title {
  text-decoration: underline;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.list {
  max-width: 760px;
  margin: 0 auto;
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  display: grid;
  gap: 10px;
}

.row {
  border: 2px solid #111;
  border-radius: 12px;
  padding: 12px;
  background: #fff;
}

.title {
  display: flex;
  align-items: center;
  gap: 10px;
  font-weight: 800;
}

.title input {
  width: 18px;
  height: 18px;
}

.panel {
  margin-top: 10px;
  border: 2px solid #111;
  border-radius: 10px;
  padding: 10px;
  background: #f2f2f2;
  max-height: 0;
  opacity: 0;
  overflow: hidden;
  transform: translateY(-6px);
  transition: max-height 240ms ease, opacity 240ms ease, transform 240ms ease;
}
  
When the checkbox is checked, the parent .row matches :has(input:checked).
This is the same pattern repeated. No extra classes needed.

Combining conditions with :has()

The selector inside :has(...) can be as simple or as fancy as you want:

  • Multiple options: :has(img, video)
  • Combine with :not(): :has(button):not(:has(button[disabled]))
  • Use :is() to keep it readable

The goal is to keep selectors understandable. Future-you is a real person with feelings.

Learn more about :is() in the CSS :is() and :where() Pseudo-Classes Interactive Tutorial.

.todo-item:has(input:checked) .text {
  opacity: 0.5;
  text-decoration: line-through;
}
  
.todo-item:has(button):not(:has(button[disabled])) {
  border-left: 10px solid #111;
}
  
.todo-item:has(:is(a, button)) .meta {
  display: block;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.todo {
  max-width: 760px;
  margin: 0 auto;
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  display: grid;
  gap: 10px;
}

.todo-item {
  border: 2px solid #111;
  border-radius: 12px;
  padding: 12px;
  background: #fff;
  display: grid;
  gap: 8px;
}

.row {
  display: flex;
  gap: 10px;
  align-items: center;
}

.row input {
  width: 18px;
  height: 18px;
}

.text {
  font-weight: 700;
}

.meta {
  border: 2px solid #111;
  border-radius: 10px;
  padding: 8px 10px;
  background: #f2f2f2;
  display: none;
}

button {
  display: inline-block;
  border: 2px solid #111;
  border-radius: 999px;
  padding: 6px 10px;
  font: inherit;
  background: #e8f7ff;
  text-decoration: none;
  color: #111;
}

button[disabled] {
  opacity: 0.5;
}
  
Buy oat milk Link
Meta appears because this item has a link or a button.
Stretch for 5 minutes
This has a button, so the meta can appear too.
Disabled action example
This still has a button, but it’s disabled.

Specificity notes with :has()

Specificity can feel spooky, but here’s the useful beginner rule: :has() contributes the specificity of the most specific selector inside it.

  • .card:has(img) is usually “mild”
  • .card:has(#bigDeal) gets “spicier” because IDs increase specificity a lot

Practical advice: avoid putting IDs inside :has() unless you truly mean it.

Learn more in the CSS Specificity Interactive Tutorial.

Real-world recipes with CSS :has()

Let’s do a recipe you’ll actually use: highlight a list item if it contains a “new” badge. No extra classes on the list item needed.

.product:has(.badge-new) {
  background: #fff6d6;
}
  
.product:has(.badge-new) .title {
  text-decoration: underline;
}
  
.product:has(img) {
  display: grid;
  grid-template-columns: 72px 1fr;
  gap: 12px;
  align-items: center;
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.products {
  max-width: 760px;
  margin: 0 auto;
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  display: grid;
  gap: 10px;
}

.product {
  border: 2px solid #111;
  border-radius: 12px;
  padding: 12px;
  background: #fff;
}

.title {
  font-weight: 900;
  margin: 0 0 6px 0;
}

.desc {
  margin: 0;
  line-height: 1.4;
}

.badge-new {
  display: inline-block;
  border: 2px solid #111;
  border-radius: 999px;
  padding: 4px 8px;
  font-size: 12px;
  background: #e8f7ff;
  margin-left: 8px;
}

.product img {
  width: 72px;
  height: 72px;
  object-fit: cover;
  border-radius: 12px;
  border: 2px solid #111;
  display: block;
}
  
Random

Coffee BeansNew

This product row is highlighted because it has .badge-new.

Random

Tea Leaves

No badge, no highlight. Still delicious.

Gift CardNew

No image here, but it still highlights because of the badge.

Learn more about display:grid; in the CSS Grid Interactive Tutorial.

Performance and best practices

:has() is powerful, but treat it like hot sauce: amazing in the right amount, regrettable if you pour the whole bottle on everything.

  • Keep selectors scoped: prefer .component:has(...) over div:has(...).
  • Avoid overly broad “page-wide” queries: don’t write body:has(.something) unless you really mean it.
  • Use it for components: cards, rows, panels, fields, nav items.
  • Prefer simple inner selectors: :has(.badge) is easier than :has(.a .b .c .d).

Browser support and progressive enhancement

Modern browser support for :has() is good, but if you need to be cautious, use progressive enhancement: write “basic” CSS first, then upgrade with @supports selector(...).

Example idea: only apply the fancy :has() styles when the browser supports the selector.

Browser support tables are tracked on Can I use.

@supports selector(.card:has(img)) {
  .card:has(img) {
    display: grid;
    grid-template-columns: 120px 1fr;
    gap: 12px;
    align-items: center;
  }
}
  
@supports selector(.card:has(img)) {
  .card:not(:has(img)) {
    background: #f2f2f2;
  }
}
  
@supports not selector(.card:has(img)) {
  .note {
    display: block;
  }
}
  
*,
::before,
::after {
  box-sizing: border-box;
}

.wrap {
  max-width: 760px;
  margin: 0 auto;
  padding: 18px;
  font-family: ui-sans-serif, system-ui, sans-serif;
  display: grid;
  gap: 12px;
}

.card {
  border: 2px solid #111;
  border-radius: 12px;
  padding: 14px;
  background: #fff;
}

.card img {
  width: 120px;
  height: 90px;
  object-fit: cover;
  border-radius: 10px;
  display: block;
  border: 2px solid #111;
}

.note {
  border: 2px solid #111;
  border-radius: 12px;
  padding: 12px;
  background: #fff6d6;
  display: none;
}
  
Your browser does not support :has(), so you’re seeing the fallback note.
Random

Progressive enhancement card

In supporting browsers, this switches layout using :has().

No image

In supporting browsers, this can get a different background via :not(:has(img)).

Common gotchas and debugging tips

  • Don’t over-scope: If nothing matches, simplify the inside selector first (try :has(img) before :has(.card > .media img)).
  • Remember direct child vs descendant: > changes everything.
  • Start small: get one rule working, then add more conditions.
  • Use DevTools: inspect the element you expect to match and confirm which rule is applying.

Quick reference cheatsheet

  • .parent:has(.child) selects a parent if it contains .child
  • .parent:has(> .child) selects a parent if it contains a direct child .child
  • .thing:not(:has(...)) selects “things that do not have something”
  • .title:has(+ .note) selects a title if it’s immediately followed by .note
  • .field:has(input:invalid) selects a wrapper when an input inside is invalid
  • @supports selector(.x:has(.y)) { ... } upgrades styles only where supported

CSS :has() Conclusion

:has() is one of those CSS features that changes how you think about styling. Use it to make components smarter, HTML cleaner, and UI states easier.