The CSS :has() selector
By Flavio Copes
The CSS parent selector styles an element from what is inside it. Forms, cards, previous siblings, quantity queries, and when JavaScript is still better.
For years CSS could only look down the tree. You could style a child or a next sibling, but not a parent based on what was inside it.
So we added classes with JavaScript: is-invalid on the field wrapper, has-image on the card, and is-checked on the label. Those classes existed only to control styles.
:has() is the parent selector. You keep the meaning in the markup. The browser looks inside, or next to, an element, and styles that element.
.field:has(input:user-invalid) {
border-color: red;
}
The wrapper turns red because the input inside it failed validation, without an extra class or script.
I use it anywhere I used to toggle a class on a parent. I still use JavaScript when the page has to do something, not just look different.
How :has() thinks
The selector is anchor:has(relative).
The anchor is the element you style. The part inside :has() is a relative selector, starting from that anchor. If anything in there matches, the anchor matches.
article:has(img) {
padding: 0;
}
“Select article if it contains an img.” The style goes on the article, not on the image.
A child combinator keeps the search shallow:
article:has(> img) {
padding: 0;
}
Now the image must be a direct child. This is easier to reason about and limits how much of a large tree the browser needs to search.
:has() also looks forward at siblings. That is the other trick people wanted for years: style an element based on what comes after it.
h1:has(+ h2) {
margin-bottom: 0.25rem;
}
If an h1 is immediately followed by an h2, tighten the gap. You are styling the heading that came first, which the old next-sibling combinator could not do because it only styles the second element.
You can try selectors like this in the CSS selector tester if you want to see a match light up on a sample tree.
Highlight a form field that failed
A label wrapping an input is the example I reach for first.
<form class="signup">
<label class="field">
Email
<input type="email" name="email" required>
</label>
<button type="submit">Subscribe</button>
</form>
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
border: 1px solid #ccc;
padding: 0.75rem;
}
.field:has(input:user-invalid) {
border-color: red;
}
.field:has(input:user-valid) {
border-color: green;
}
:invalid is true as soon as the page loads, because an empty required field is invalid. That paints every required field red before the reader types. :user-invalid waits until the person interacted with the field, or tried to submit.
This sits on top of normal HTML form validation. The browser already knows the field is wrong. You are only dressing the wrapper.
You can lift the same idea to the whole form:
.signup:has(:user-invalid) {
box-shadow: 0 0 0 1px red;
}
I would keep that light. A red ring on the form is enough. Do not hide the submit button just because one field is empty. The reader still needs a way to try.
Cards that change when they contain something
A card with an image wants different padding than a text-only card.
<article class="card">
<img src="/images/css-grid.jpg" alt="">
<div class="card-body">
<h2>CSS Grid</h2>
<p>Two-dimensional layout without hacks.</p>
</div>
</article>
<article class="card">
<div class="card-body">
<h2>CSS Units</h2>
<p>px, rem, em, and when to use each.</p>
</div>
</article>
.card {
padding: 1rem;
}
.card:has(img) {
padding: 0;
}
.card:has(img) .card-body {
padding: 1rem;
}
Cards without an image keep their padding. Cards with an image go edge to edge, and the body gets the inset.
The same pattern works for a featured flag in the HTML:
section:has(.featured) {
border: 2px solid blue;
}
You style the section because one article inside it is featured. The featured article can keep its own styles.
For responsive cards, :has(img) checks what is inside while container queries check how wide the slot is. A wide card with an image is a different layout from a narrow text card.
Style a label from a checkbox
:has() works with other pseudo-classes.
<label class="option">
<input type="checkbox" name="topics" value="css">
CSS
</label>
.option {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
}
.option:has(input:checked) {
background: #e8f5e9;
font-weight: bold;
}
.option:has(input:disabled) {
opacity: 0.5;
}
The label reacts to the control inside it. You do not need the old input + span trick, and you do not need the checkbox to be a sibling of the text.
Radio groups and select elements work the same way:
.field:has(select:required:invalid) {
border-color: red;
}
Style the previous sibling
:has() can also style the sibling that comes before another element.
h1:has(+ p) {
margin-bottom: 0.5rem;
}
figure:has(figcaption) img {
margin-bottom: 0.25rem;
}
li:has(+ li) {
border-bottom: 1px solid #ddd;
}
h1:has(+ p) styles the heading when a paragraph follows it. li:has(+ li) styles every item that is not the last one, because a next li exists.
You can also look further:
h2:has(~ h2) {
/* this h2 is not the last h2 in the block */
}
+ is the next sibling. ~ is any later sibling.
I use this for heading spacing more than anything else. Two headings in a row should sit closer than a heading followed by a paragraph. That used to be a special class or a :has()-less compromise on the second heading.
Quantity queries
:has() plus :nth-child lets you style a list based on how many items it has.
.tags:has(> * :nth-child(2)) {
/* at least 2 tags */
}
.tags:has(> * :nth-child(5)) {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.gallery:has(> img:only-child) {
max-width: 28rem;
}
:has(> * :nth-child(5)) is true when a fifth child exists, so the list has at least five items. A short tag list can stay inline. A long one wraps.
A gallery with one image should not stretch like a five-image grid. :only-child says that without counting in JavaScript.
AND versus OR
A comma inside :has() is OR. Chaining :has() is AND.
body:has(video, audio) {
/* the page has a video OR an audio player */
}
body:has(video):has(audio) {
/* the page has both */
}
.card:has(img):has(figcaption) {
/* image and caption */
}
I almost never put :has() on body; this example only shows the logic. I normally anchor it to a smaller element.
:is() keeps long lists readable:
:is(h1, h2, h3):has(+ :is(h2, h3, h4)) {
margin-bottom: 0.25rem;
}
Any of those headings, followed by a slightly smaller heading, gets a tighter margin.
Combining with :not()
Exclude the cases you do not want:
.nav:has(a.active):not(:has(.dropdown)) {
border-bottom: 2px solid blue;
}
.card:not(:has(img)) {
background: #f6f6f6;
}
The first rule styles a nav that has an active link, but only when there is no dropdown inside. The second is often clearer than inventing a card--text-only class.
.card:not(:has(img)) and .card:has(img) are a good pair, with one rule for text cards and another for image cards.
Specificity
:has() does not add its own specificity. It takes the specificity of the most specific selector in its argument, the same way :is() and :not() do.
.card {
/* 0, 1, 0 */
}
.card:has(img) {
/* 0, 1, 1 (.card + img) */
}
.card:has(.hero-image) {
/* 0, 2, 0 (.card + .hero-image) */
}
.card:has(.hero-image) beats .card:has(img). If a rule “does not apply,” check whether a more specific :has() won. The CSS specificity guide is the longer version of this.
Because the argument counts, a sloppy :has() can become a specificity hammer:
.card:has(#intro img.hero) {
/* 1, 2, 1 — this will bully a lot of other rules */
}
Keep the selector inside :has() limited to the class or element you need.
What :has() cannot do
You cannot nest :has() inside :has(). This is invalid:
article:has(div:has(img)) {
/* no */
}
Write the descendant you mean:
article:has(img) {
/* yes */
}
Pseudo-elements are not allowed inside :has(), and :has() cannot hang off a pseudo-element.
article:has(::before) {
/* no */
}
article::before:has(img) {
/* no */
}
The reason is cycles. ::before exists because of styles. If styles could ask whether ::before exists, the browser would chase its own tail.
If a browser does not support :has(), the whole selector is dropped. The rest of the rule does not apply. Wrap a fallback with a feature query when the layout would break without it:
.card {
padding: 1rem;
}
@supports selector(:has(*)) {
.card:has(img) {
padding: 0;
}
}
For current Chrome, Firefox, and Safari you do not need that. I add @supports when the unstyled state would be wrong, not just less fancy.
Do not put it on the whole page
:has() is not free. On every DOM change, the browser has to ask: does this still match?
If the anchor is body, :root, or *, every mutation can walk a huge tree.
/* don't */
body:has(.sidebar-open) {
overflow: hidden;
}
*:has(.item) {
color: red;
}
Anchor to the element that actually changes:
.layout:has(> .sidebar-open) {
overflow: hidden;
}
.gallery:has(> img[data-loaded='false']) {
opacity: 0.7;
}
Prefer > and + inside :has() when you can. .card:has(> img) checks one level. .card:has(img) may walk everything inside the card.
Use :has() for local UI such as a field, card, nav, or list. For a page-wide mode like “the mobile menu is open, lock the body,” a single class on body from a click handler avoids a tree walk on every keystroke.
How I would use this
I would use :has() on forms first.
The newsletter form on /access and the waiting-list forms on the course pages are labels plus inputs. A red border on .field when the email is :user-invalid is exactly the old is-invalid class, without the class.
I would use it on cards that optionally have an image or a “featured” mark. This blog’s posts are mostly text. When a card does include an image, I want the padding to collapse without a has-image modifier in the HTML.
I would use h1:has(+ h2) and h2:has(+ h3) in long articles. Heading pairs should sit tighter than a heading plus a paragraph. That is a spacing bug I have fixed with extra classes too many times.
I would not use it to replace application state. Opening a dialog, submitting a form, filtering a list: those are still JavaScript. A dialog element and a click handler do that job. :has() can style the page around an open dialog if you want, but it should not be the thing that opens it.
I would not write body:has(...) to theme the whole site from a checkbox in the footer. It works in a demo. It is a performance footgun on a real page.
The free CSS course covers selectors and pseudo-classes first. :has() is easier once :hover, :checked, and :not() already feel normal.
For nesting, container queries, and the rest of the no-build-step list, see 12 CSS features you can use today.
Browser support
:has() has been in all modern browsers since December 2023. It is safe in production now.
Safari was last. If you still see a screenshot from 2022 saying “Chrome only,” that page is outdated.
My advice: use :has() when the only reason you opened JavaScript was to add a class on a parent. Keep that state in the HTML for CSS to read. Reach for a script when the user action has to change more than styles.
Want me to talk about your product? You can sponsor this site.