CSS :has() is a relational pseudo-class: it matches an element based on what it contains rather than on its own attributes. That one capability lets a pricing table highlight the row that carries a badge, a checkout form show a warning because one field is invalid, and a card change its proportions when it has no image — all in CSS, with no script watching for changes.
What does :has() add that :not() cannot?
Before :has(), a selector could only travel in one direction. You could match an element by its own attributes, you could match a descendant of something, and you could match an ancestor of something — but you could never ask a question about a node's children in order to style the node itself. The engine knew how to find a paragraph inside a card; it had no way to say "and then style the card".
:has() flips the direction of travel. The selector .card:has(.badge) matches a card that contains a badge somewhere inside it. The badge is the argument, and the card is the subject. :not() negates a selector; :has() relates a selector to another element entirely.
The capability is supported in current evergreen browsers, so it is no longer an experimental technique you need a polyfill for. What remains is a discipline problem: :has() is powerful enough to make a stylesheet unreadable if you reach for it first. Use it when the relationship is genuinely about the parent, and a class in the markup when the relationship is really about the component.
<table class="pricing">
<tr><th scope="col">Plan</th><th scope="col">Price</th></tr>
<tr><td>Starter</td><td>$0</td></tr>
<tr><td>Team <span class="badge">Best value</span></td><td>$24</td></tr>
<tr><td>Scale</td><td>$79</td></tr>
</table>
<form class="checkout">
<label for="card">Card number</label>
<input id="card" required>
<button type="submit">Pay</button>
</form>Which relationships can the argument of :has() express?
The argument is a relative selector list, so it can describe several kinds of relationship. Writing a leading greater-than sign asks for a direct child. Writing nothing asks for any descendant. A leading plus sign asks for the immediately following sibling, and a leading tilde asks for any following sibling. You can also list several alternatives, separated by commas, and the element matches if any one of them is found.
/* Any card that contains an image somewhere inside */
.card:has(img) {
border-color: #2f6fed;
}
/* Only cards whose direct child is the image */
.card:has(> .thumb) {
padding: 0;
}
/* A term whose definition follows it immediately */
dt:has(+ dd) {
font-weight: 700;
}
/* A heading that has a heading after it — that is, not the last one */
section:has(h2 ~ h2) {
padding-bottom: 0;
}How does :has() affect specificity?
The specificity of :has() is replaced by the specificity of the most specific complex selector in its argument list. This behaves exactly like :is() and :not(). A pseudo-class in the class column plus the argument's own weight gives the total.
So .card:has(.badge) has a specificity of (0,2,0): one class for .card and one more for the .badge inside the argument. A rule of .card on its own, at (0,1,0), loses to it. And .card:has(h2) is (0,1,1), because a type selector in the argument only adds to the type column.
This is worth internalising because it means :has() can quietly outrank rules you wrote by hand. If a :has() rule is unexpectedly winning, count the argument before you hunt for the bug.
/* (0,1,0) */
.card {
border: 1px solid #d7e0e8;
}
/* (0,2,0) — .card plus the class inside :has() */
.card:has(.badge) {
border: 2px solid #2f6fed;
}
/* (0,2,0) — an attribute argument counts like a class too */
.card:has([data-sale]) {
background: #fff8e6;
}What are the limits of :has()?
Three rules are worth keeping. First, :has() cannot be nested inside another :has(); the specification does not allow it, and a browser rejects the whole selector if you try. Second, pseudo-elements are not valid inside :has(), because many of them exist conditionally on the styling of their ancestors, which would create a circular dependency. Third, order matters enormously when you combine :has() with :not().
That third point causes real confusion. The selector section:not(:has(h2, h3)) means "sections that contain no heading at all", which is what you want when auditing a page for missing structure. The selector section:has(:not(h2, h3)) means something quite different: "sections that contain something that is not a heading". Almost every section matches that, because sections contain paragraphs. The two are not interchangeable, and the difference is where the argument sits relative to the negation.
/* Sections with no heading — a real audit rule */
section:not(:has(h2, h3)) {
border-inline-start: 3px solid #b3261e;
padding-inline-start: 1rem;
}
/* A form that contains a field the user has left invalid */
.checkout:has(input:user-invalid) {
border-inline-start: 4px solid #b3261e;
}
.checkout:has(input:user-valid) {
border-inline-start: 4px solid #1f7a4d;
}
/* A card that has no image gets a taller, text-only layout */
.card:not(:has(img)) {
grid-template-columns: 1fr;
}Where does :has() fit into a real layout?
The pattern that repeats across projects is parent awareness: a container that changes because of what it holds. The pricing table above highlights the row carrying a badge, so the recommended plan is obvious without a single extra class in the markup. The checkout form grows a coloured edge when a field is in a bad state, which means the feedback appears even when the invalid field is scrolled out of view. A card grid can give text-only cards a different internal arrangement than cards with photography.
Navigation is another good fit. A drawer or a disclosure can be styled from the control that drives it, so the button and the panel can never fall out of sync: button[aria-expanded="true"] is a state you can already read, and with :has() the surrounding container can respond to it too.
Keep the argument cheap. The engine has to evaluate the descendants of an element and then work out how that affects the ancestors, so a simple class or attribute argument is far kinder than a long chain of descendant selectors. Arguments like :has(.card .badge .label) do a lot of work for very little result.
/* The panel follows the state of its own toggle */
.drawer:has(.drawer-toggle:checked) {
transform: translateX(0);
}
.drawer-toggle:checked + .drawer-panel {
display: block;
}
/* A list whose items all have images gets a tighter grid */
.gallery:has(.item img) {
gap: 0.75rem;
}
/* A gallery with no images at all falls back to a text layout */
.gallery:not(:has(img)) {
grid-template-columns: 1fr;
}Parent-aware styling in one paragraph
- :has() matches an element because of what it contains, which is the one relationship plain selectors could not express.
- The argument accepts relative selectors: a leading
>for a direct child, a leading+for the next sibling, a leading~for any following sibling, and nothing for any descendant. - The specificity of
:has()is the specificity of its most specific argument, so.card:has(.badge)is (0,2,0). - You cannot nest
:has()inside:has(), and pseudo-elements are not valid arguments. :not(:has(h2))and:has(:not(h2))mean opposite things. Decide where the negation belongs before you write it.- Keep arguments short. A single class or attribute is cheap; a deep descendant chain is not.
Try it yourself: build a pricing table with three rows and put a badge in one of them, then style only the row that contains a badge using tr:has(.badge). Add a third row and confirm the highlight moves with the badge rather than staying on a fixed row number. Next, wrap a text input in a form and give the form a coloured edge with form:has(input:user-invalid), and confirm the edge appears only after you type and leave a bad value. Finally, add a rule with section:not(:has(h2)) and check how many sections it flags; then move the negation to the outside of :has() and watch the meaning invert.