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.parentif it contains an element matching.child”. -
.parent:has(> .child)means “select.parentif it has a direct child matching.child”. -
.item:has(+ .item)means “select.itemif 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;
}
![]()
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.
![]()
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 badgeThis 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;
}
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.rowmatches: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;
}
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;
}
![]()
Coffee BeansNew
This product row is highlighted because it has
.badge-new.![]()
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(...)overdiv: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.![]()
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.
