
The CSS :has() Selector: The Parent Selector We Always Wanted
For most of CSS's history, selectors only worked in one direction: down. You could style a child based on its parent, a sibling based on an earlier sibling, but never a parent based on its children. "Can I select the parent of this element?" was asked on forums so often that the answer ("no, use JavaScript") became a meme of its own.
The :has() pseudo-class changed that. It lets you select an element based on what it contains, or what follows it. It's supported in every major browser, and once you start using it, you'll find it replaces a surprising amount of JavaScript and a lot of awkward modifier classes. This guide explains how :has() works, walks through practical examples, and covers the performance and specificity details worth knowing.
What :has() Actually Does
:has() is a relational pseudo-class. It takes a list of relative selectors as its argument and matches an element if at least one of those selectors matches when anchored at that element.
/* Any .card that contains an img */
.card:has(img) {
padding-top: 0;
}
Read it as "a .card that has an img somewhere inside it". The styles apply to the .card, not the image. That's the parent-selector behavior people wanted for years.
The argument is a relative selector, which means it can start with a combinator:
/* .card with an img as a direct child */
.card:has(> img) {
}
/* h2 immediately followed by a p */
h2:has(+ p) {
}
/* h2 followed (anywhere later among siblings) by a .note */
h2:has(~ .note) {
}
When there's no combinator, a descendant combinator is implied, so .card:has(img) is the same as .card:has( img).
It's Not Just a Parent Selector
Calling :has() a "parent selector" undersells it. Because the argument can use any combinator, it can reach:
- Parents:
li:has(> a.active)selects the list item containing the active link. - Ancestors:
section:has(.error)selects any section with an error somewhere inside. - Previous siblings:
label:has(+ input:invalid)selects a label whose next sibling is an invalid input. - Unrelated elements via a common ancestor:
body:has(dialog[open])selects the body when any dialog is open, letting you style anything on the page.
That last pattern is powerful: once you can condition on body or html, you effectively have global state in CSS.
Practical Examples
Cards with and without images
A common design has cards that look different when they include a media element:
<article class="card">
<img src="/images/harbor.jpg" alt="Harbor at dawn" />
<h3>Morning in the Harbor</h3>
<p>A short walk along the water.</p>
</article>
<article class="card">
<h3>Release Notes</h3>
<p>Version 4.2 fixes three bugs.</p>
</article>
.card {
display: grid;
gap: 0.75rem;
padding: 1.25rem;
border-radius: 12px;
background: #f8fafc;
}
.card:has(> img) {
padding: 0 0 1.25rem;
}
.card:has(> img) > :not(img) {
padding-inline: 1.25rem;
}
.card > img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 12px 12px 0 0;
}
No .card--with-image class is needed. If the content editor removes the image, the card adjusts on its own.
Form validation styling
Styling a whole form field wrapper when its input is invalid used to need JavaScript. Now it's a single rule:
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" required />
<p class="field-hint">We'll never share your email.</p>
</div>
.field:has(input:user-invalid) {
--field-color: #dc2626;
}
.field:has(input:user-invalid) label,
.field:has(input:user-invalid) .field-hint {
color: var(--field-color);
}
.field:has(input:user-invalid) input {
border-color: var(--field-color);
}
Using :user-invalid instead of :invalid matters here. :invalid matches as soon as the page loads for an empty required field, which shows errors before the user has typed anything. :user-invalid only matches after the user has interacted with the field. It's supported in current versions of all major browsers; if you need to support older ones, :invalid:not(:placeholder-shown) is a common approximation (with a placeholder attribute set on the input).
You can also disable a submit button's styling until the form is valid:
form:has(:invalid) button[type="submit"] {
opacity: 0.5;
cursor: not-allowed;
}
Keep in mind that this only changes appearance. The button still works, and the browser's built-in validation will still run on submit.
Highlighting a table row on checkbox selection
<table class="inbox">
<tr>
<td><input type="checkbox" aria-label="Select message" /></td>
<td>Invoice for September</td>
</tr>
</table>
.inbox tr:has(input[type="checkbox"]:checked) {
background: #e0f2fe;
}
Quantity queries
You can style a container differently based on how many children it has:
/* A grid with 5 or more items switches to 3 columns */
.gallery:has(> :nth-child(5)) {
grid-template-columns: repeat(3, 1fr);
}
/* Exactly one item: make it full width */
.gallery:has(> :only-child) {
grid-template-columns: 1fr;
}
.gallery:has(> :nth-child(5)) reads as "a gallery that has a fifth child", which is true whenever there are at least five.
Preventing background scroll when a modal is open
html:has(dialog[open]) {
overflow: hidden;
}
When any <dialog> is open, the page behind it stops scrolling. Remove the open attribute, and scrolling returns. You might prefer to scope this to modal dialogs only, for example with html:has(dialog:modal).
Styling previous siblings
Hover effects that affect the items before the hovered one, such as star ratings, used to require reversed DOM order tricks. With :has():
<div class="rating">
<button class="star" aria-label="1 star"></button>
<button class="star" aria-label="2 stars"></button>
<button class="star" aria-label="3 stars"></button>
<button class="star" aria-label="4 stars"></button>
<button class="star" aria-label="5 stars"></button>
</div>
.star {
width: 32px;
aspect-ratio: 1;
border: 0;
background: #cbd5e1;
clip-path: polygon(
50% 0,
61% 35%,
98% 35%,
68% 57%,
79% 91%,
50% 70%,
21% 91%,
32% 57%,
2% 35%,
39% 35%
);
cursor: pointer;
}
/* The hovered star and every star before it */
.star:hover,
.star:has(~ .star:hover) {
background: #fbbf24;
}
.star:has(~ .star:hover) matches any star that has a hovered star somewhere after it, which is exactly every star before the hovered one.
Theme switching without JavaScript
With a checkbox or a select in the page, you can drive a theme entirely from CSS:
<label class="theme-toggle">
<input type="checkbox" id="dark-mode" />
Dark mode
</label>
:root {
--bg: #ffffff;
--text: #0f172a;
}
:root:has(#dark-mode:checked) {
--bg: #0f172a;
--text: #f1f5f9;
}
body {
background: var(--bg);
color: var(--text);
}
The preference won't persist across page loads without a bit of JavaScript and storage, but the styling logic lives entirely in CSS.
Combining :has() with :not()
Negation opens up even more options:
/* Sections that do NOT contain a heading */
section:not(:has(h2, h3)) {
outline: 2px dashed #f472b6; /* a handy debugging aid */
}
/* Images without alt text */
img:not([alt]) {
outline: 4px solid #dc2626;
}
/* Links that contain only an image, with no text */
a:has(> img:only-child) {
display: inline-block;
}
Note the difference between :not(:has(x)) (the element contains no x) and :has(:not(x)) (the element contains at least one thing that isn't x). They are very different selectors, and mixing them up is a common source of confusion.
Specificity of :has()
:has() itself adds no specificity, but it takes the specificity of its most specific argument, just like :is() and :not().
.card:has(img) {
} /* (0, 1, 1): class + type */
.card:has(#hero) {
} /* (1, 1, 0): the ID counts */
.card:has(img, #hero) {
} /* (1, 1, 0): the most specific argument wins */
That last one can surprise you. Even if the card only contains an img, the selector carries ID-level specificity because #hero is in the list. If you need to keep specificity low, wrap it in :where():
.card:where(:has(img, #hero)) {
} /* (0, 1, 0) */
Limitations and Gotchas
No nesting :has() inside :has()
You can't put :has() inside another :has(). Browsers treat .a:has(.b:has(.c)) as invalid. Usually you can restructure the selector: .a:has(.b .c) covers many of the same cases.
Pseudo-elements aren't allowed inside
:has(::before) doesn't work. The argument can only reference real elements.
Forgiving vs. unforgiving parsing
:has() uses an unforgiving selector list. If any selector in the list is invalid, the entire rule is dropped. That affects feature detection and mixing with experimental selectors:
/* If :some-future-thing isn't supported, the whole rule fails */
.card:has(img, :some-future-thing) {
}
Split risky selectors into separate rules.
Feature detection
To provide fallbacks, use @supports selector():
.field.is-invalid label {
color: #dc2626; /* JS-driven fallback class */
}
@supports selector(:has(a)) {
.field:has(input:user-invalid) label {
color: #dc2626;
}
}
Given that :has() is available in all current major browsers, many teams no longer bother with fallbacks for non-critical styling, but it's still a good idea when a :has() rule controls something essential like visibility.
Performance
Early concerns about :has() centered on performance. A selector like body:has(.x) means the browser, in principle, might need to re-check the body every time anything in the document changes. Browser engines have put considerable work into optimizing this with invalidation strategies, and in normal use :has() is fast.
Still, a few habits help:
- Anchor to a specific element rather than a universal one.
.form-field:has(:invalid)is cheaper to evaluate than:has(:invalid)(which implicitly means*:has(:invalid)). - Prefer child or sibling combinators when you can.
:has(> img)limits the search to direct children, while:has(img)searches the whole subtree. - Be cautious with
:has()onhtmlorbodyin apps that make rapid, large DOM changes, such as virtualized lists or real-time dashboards. Profile if you notice style recalculation costs in DevTools.
Browser Support
:has() is supported in all current versions of Chrome, Edge, Safari, and Firefox. Firefox was the last major engine to ship it, and it has now been available across the board long enough that it's safe for most production projects. If your audience includes older browsers, use @supports selector(:has(a)) to gate critical styles.
Conclusion
The :has() selector finally lets CSS look up and across the DOM, not just down. It can style parents based on children, previous siblings based on later ones, and entire pages based on a single element's state. That replaces JavaScript class toggling for form feedback, selection highlighting, modals, and layout variations, and it removes a lot of modifier classes from your markup.
Start small: replace one --has-image or is-invalid class in your codebase with a :has() rule. Keep an eye on specificity when you pass complex selectors, anchor your selectors to specific elements, and you'll have one of the most expressive tools CSS has ever shipped working for you.


