For years, CSS could only style an element based on what came before it or what it sat inside. You could not say “style this card if it contains an image”. The CSS has selector, written :has(), changes that. It lets an element react to its children, its later siblings, and even how many items it holds, with no JavaScript.
This guide explains what :has() does, the syntax rules that trip people up, eight examples you can use in real projects, browser support, and the mistakes I see most often in junior code reviews. If selectors still feel new, read the CSS Selectors lesson first.
What the CSS has selector actually does
:has() is a relational pseudo-class. You write an anchor element, then put a relative selector inside the brackets. The anchor matches if at least one element matches that relative selector when measured from the anchor.
/* Any .card that contains an <img> anywhere inside it */
.card:has(img) {
padding-top: 0;
}
/* Only when the <img> is a direct child */
.card:has(> img) {
padding-top: 0;
}People call it the “parent selector”, but that undersells it. Because the argument is a relative selector, you can start it with any combinator. That opens up three main patterns:
| Pattern | Example | Reads as |
|---|---|---|
| Parent / ancestor | .card:has(img) | A card that contains an image |
| Previous sibling | h2:has(+ p) | An h2 that is directly followed by a paragraph |
| Quantity query | ul:has(> li:nth-child(5)) | A list with at least five items |
| AND | .form:has(input):has(select) | A form containing both an input and a select |
| OR | .form:has(input, select) | A form containing an input or a select |
Syntax rules you need to know
The argument is an unforgiving selector list
Selectors Level 4 defines :has() as taking a <relative-selector-list>. Early browser versions parsed it in a “forgiving” way, but the CSS Working Group changed this (issue 7676), and the current draft lists :has() as “no longer forgiving”. In practice: if one selector in the list is invalid, the whole rule is thrown away.
/* :unknown is invalid, so this entire rule is dropped */
.card:has(img, :unknown) { border: 2px solid; }
/* :is() takes a forgiving list, so the valid part still works */
.card:has(:is(img, :unknown)) { border: 2px solid; }Only :is() and :where() are forgiving. Wrap the risky part in one of them if you must mix newer selectors into a list.
No :has() inside :has(), and no pseudo-elements
The spec is clear: :has() cannot be nested inside another :has(). Pseudo-elements such as ::before are also not valid inside it, and cannot be the anchor either. MDN explains why: many pseudo-elements exist only because of their ancestors’ styles, so querying them would create loops.
One more rule: the specificity of :has() is the specificity of the most specific selector in its argument, the same way :is() and :not() work. .card:has(#hero) carries ID-level weight.
8 real-world CSS has selector examples
1. A card that changes layout when it has an image
.card {
display: grid;
gap: 1rem;
}
.card:has(> img) {
grid-template-columns: 160px 1fr;
}Text-only cards stay single column; cards with a thumbnail become two columns. The CMS editor never needs to add a modifier class.
2. A form field with an invalid input
.field:has(input:invalid:not(:placeholder-shown)) {
border-left: 4px solid #c62828;
}
.field:has(input:invalid:not(:placeholder-shown)) .hint {
color: #c62828;
}The :not(:placeholder-shown) part stops empty fields turning red on page load, but it only works if the input has a placeholder attribute. Colour alone is not enough for accessibility, so keep a text error too. My HTML form best practices post covers accessible error messages.
3. A label wrapping a checked checkbox
label:has(input[type="checkbox"]:checked) {
background: #e8f5e9;
border-color: #2e7d32;
}This is perfect for “choose your plan” or filter chips, where the whole tile should look selected.
4. Navigation with an open submenu
.site-nav:has([aria-expanded="true"]) {
box-shadow: 0 4px 12px rgb(0 0 0 / 0.15);
}
.nav-item:has(> .submenu:hover, > .submenu:focus-within) > a {
text-decoration: underline;
}Your JavaScript still toggles aria-expanded on the button, which it should do anyway for screen readers. CSS then reacts to that state, so you do not need a second class like .is-open.
5. Quantity queries
/* At least 4 items: switch to a two-column grid */
.tag-list:has(> li:nth-child(4)) {
display: grid;
grid-template-columns: 1fr 1fr;
}
/* Exactly one item */
.tag-list:has(> li:only-child) {
justify-content: center;
}If a fourth child exists, the list has four or more items. This replaces older tricks that needed long chains of :nth-last-child.
6. Dark mode with a checkbox
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
:root:has(#dark-toggle:checked) {
--bg: #121212;
--text: #eaeaea;
color-scheme: dark;
}
body {
background: var(--bg);
color: var(--text);
}This toggles custom properties with no script. It does not remember the choice after a reload, though; for that you still need a small amount of JavaScript and localStorage. See Dark Mode Design and CSS for the full version.
7. Previous sibling: tighten a heading before a subtitle
h2:has(+ .subtitle) {
margin-bottom: 0.25rem;
}8. Empty states
.results:not(:has(.result-item)) .empty-message {
display: block;
}When the list holds no results, the friendly empty message appears. You can try any of these in the Code Playground.
Browser support and feature detection
MDN marks :has() as Baseline “widely available” and says it has worked across browsers since December 2023. Can I use lists first support as Chrome and Edge 105, Safari 15.4, Firefox 121 and Samsung Internet 20. Support figures change, so check caniuse at the time you ship.
For older browsers, treat the CSS has selector as an enhancement. Write a solid base style, then layer :has() on top. If you need a different fallback, use feature detection:
@supports selector(:has(a)) {
.card:has(> img) {
grid-template-columns: 160px 1fr;
}
}
@supports not selector(:has(a)) {
.card--with-image {
grid-template-columns: 160px 1fr;
}
}Tip: The same selectors work in JavaScript: document.querySelectorAll(‘.card:has(> img)’) returns every card with a direct image child in supporting browsers.
Performance notes
When the page changes, the browser has to re-check :has() rules, so the shape of your selector matters. MDN’s advice boils down to this: in A:has(B), keep A narrow and B tightly constrained.
- Avoid broad anchors like
body,:rootor*paired with a loose inner selector..cart:has(...)is better thanbody:has(...). - Use
>or+inside the brackets so the browser checks fewer elements:.card:has(> img)rather than.card:has(img). - Avoid patterns like
.box:has(.foo > *), which can force extra ancestor checks. Target a specific class instead. - The dark mode example uses
:root, but its inner selector is a single ID, which keeps it cheap. Use that kind of global switch sparingly.
For most sites this will never be a bottleneck, but good habits cost nothing. My CSS animation performance tips cover the rendering side.
Common mistakes with :has()
- Forgetting the child combinator.
.card:has(img)also matches an image buried in a nested component. Add>when you mean direct children. - Mixing an unsupported selector into the list. One invalid item kills the rule. Wrap it in
:is(). - Nesting :has() or using ::before inside it. Both are invalid, and the rule silently does nothing.
- Being surprised by specificity. An ID inside
:has()raises the weight of the whole selector, which can make later overrides fail. - Replacing real state with CSS tricks. A checkbox toggle is fine for a demo, but menus and dialogs still need correct ARIA attributes and keyboard support.
- Skipping a fallback when the feature is essential, such as showing an error. If it must work everywhere, keep a class-based path.
Start with one small use, such as the card or checkbox example, in your next project. Once you are comfortable, the CSS has selector will remove a lot of class juggling from your JavaScript.
Frequently asked questions
Is :has() really a parent selector?
It can select a parent, but it is more general. It matches an element based on any relative selector, so it also handles previous siblings (h2:has(+ p)) and quantity checks.
Can I use :has() in production?
MDN lists it as Baseline widely available since December 2023, and all major current browsers support it. If visitors use very old browsers, add a fallback with @supports not selector(:has(a)).
Why does my :has() rule do nothing?
Common causes are an invalid selector inside the list, a nested :has(), a pseudo-element such as ::before inside it, or an older browser. Inspect the element in your browser’s DevTools: if the rule is missing from the styles list, the browser has probably rejected the selector.
Does :has() work with JavaScript querySelector?
Yes. In supporting browsers, document.querySelector() and querySelectorAll() accept :has() like any other selector.


