CSS :has() Selector Table
| Pattern | Selects | Field note |
|---|---|---|
div:has(img) | Div containing an img | The parent selector - CSS's most-requested feature, shipped 2023 |
label:has(+ input:invalid) | Label before invalid input | Sibling scan AFTER the label - form validation styling |
li:has(ul) | List item containing sublist | Expandable-tree styling without JS classes |
figure:has(figcaption) | Figure with caption | Style captioned vs bare figures differently |
card:has(.badge) | Card containing a badge | E-commerce: style products with sale badges |
form:has(input:required) button | Submit in forms with required fields | Scope button styling to required forms |
a:not(:has(img)) | Text-only links | The not() composition: links without images |
:has() is the parent selector: it styles an element based on what's INSIDE it. div:has(img) targets a div containing an image; label:has(+ input:invalid) targets a label whose adjacent input is invalid. CSS could not look upward for 25 years, and :has() ended that in every major browser by 2023.
Bottom line: :has() replaces the JS class-toggle pattern - the addHasImageToParent workflow that JavaScript frameworks institutionalized. A card containing a .badge gets a border; a form containing an invalid input gets a red header; a list item containing a sub-list expands - all in pure CSS. The wrapper-div-for-styling era is over.
The honest part: :has() invalidates upward. A change inside a matched child restyles the parent and all its children - fine for forms and cards, expensive on huge lists. And the browser support cutoff is real: this is a 2023+ feature, so legacy browser support still needs a JS fallback for critical layouts.
How to use
- Start with the parent pattern: div:has(img) - the simplest case that replaces a JS class toggle.
- Combine with other selectors: label:has(+ input:invalid) uses the adjacent sibling combinator AFTER :has() to scan forward.
- Test the invalidation cost: :has() on huge lists (1000+ children) can cause performance issues - gate with a class or narrow the scope.
Frequently asked questions
What makes :has() different from every other CSS selector?
Every other selector works DOWNWARD (descendant, child) or SIDEWAYS (sibling) - :has() is the first that works UPWARD, letting a parent be styled based on its children. This breaks the fundamental CSS rule that styles flow down, which is why it took 25 years and required rethinking the engine's invalidation model. The parent selector was the single most-requested CSS feature of all time.
How does :has() work with the + combinator for form validation?
label:has(+ input:invalid) scans FORWARD from the label to the adjacent sibling (the input) and styles the label if that input matches :invalid. The + combinator inside :has() means the input must be the IMMEDIATE next sibling. This enables error-state styling on the label without JavaScript - the browser does the validation check and the selector does the styling in one line of CSS.
Is :has() a performance problem?
It can be, on the wrong scale. :has() creates an upward invalidation: when a child inside a :has()-matched subtree changes, the browser must re-evaluate the parent and potentially all its descendants. For forms and cards (tens of children), this is negligible. For a list with thousands of items, it can cause jank. The mitigation: scope :has() to a reasonable subtree, and avoid it on flat lists where a class toggle would work.
Does :has() work in every browser?
Every major browser shipped it in 2023: Chrome 105, Safari 15.4, Firefox 121. That makes it broadly available in 2024+, but legacy enterprise environments and older mobile browsers may lack it. The progressive enhancement pattern: use @supports selector(:has(*)) to gate styles behind support, and keep a JS fallback (MutationObserver adding a class) for critical layouts only.