CSS :has() — The Complete Parent Selector Guide
CSS :has() is a relational pseudo-class that matches an element when it contains a descendant or sibling matching the selector inside its parentheses. It is the long-awaited parent selector: article:has(img) styles the article that contains an image. With combinators it also acts as a previous-sibling selector. :has() ships in all evergreen browsers (Safari 15.4+, Chrome 105+, Firefox 121+) and is defined in CSS Selectors Level 4.
For over twenty years, developers asked for one thing more than almost anything else in CSS: a parent selector. The ability to style an element based on what it contains rather than where it sits was the most requested CSS feature in every survey, every conference talk Q&A, and every specification discussion. The reason it took so long was performance. Early CSS engines evaluated selectors right-to-left, and a naive parent selector could force the browser to re-evaluate the entire document tree every time a child changed. It was not until browser engine teams found efficient invalidation strategies (Bloom filters, ancestor marking) that the feature became viable.
The :has() pseudo-class, officially defined in CSS Selectors Level 4, landed in browsers starting in late 2022. It is far more than a simple parent selector. It is a relational pseudo-class that matches an element if any element matching its argument exists relative to the subject. You can select parents based on children, siblings based on other siblings, ancestors based on deeply nested descendants, and more. It is, without exaggeration, the most powerful selector CSS has ever shipped. For the canonical syntax reference see MDN: :has().
This guide covers the full scope of :has(): from basic parent selection to advanced patterns involving combinators, pseudo-classes, form states, quantity queries, and performance considerations. You will finish with a deep practical understanding of where and how to use :has() in production.
Basic Parent Selection
The most straightforward use of :has() is selecting a parent element based on its children. The syntax reads naturally: select an element that has a child matching the given selector.
/* Select any figure that contains a figcaption */
figure:has(figcaption) {
border: 1px solid #ddd;
padding: 1rem;
}
/* Select any article that contains an img */
article:has(img) {
display: grid;
grid-template-columns: 1fr 2fr;
}
/* Select a div that directly contains an h2 */
div:has(> h2) {
margin-bottom: 2rem;
}
Notice the third example uses the child combinator > inside :has(). Without it, the selector matches if an h2 exists anywhere among the descendants. With >, it only matches direct children. This distinction is critical for precise targeting.
The subject of the selector — the element being styled — is the element before :has(). The argument inside :has() describes a relationship that must exist. The matched descendant itself is not styled; the ancestor is.
Using Combinators Inside :has()
The real power of :has() emerges when you use CSS combinators inside the argument. This lets you express relationships that were previously impossible in CSS.
Child Combinator (>)
Restrict matching to direct children only:
/* List items that directly contain a link */
li:has(> a) {
list-style: none;
}
/* Sections whose first child is an h2 */
section:has(> h2:first-child) {
border-top: 3px solid #0066cc;
}
Adjacent Sibling Combinator (+)
This is where :has() transcends parent selection entirely. By using the adjacent sibling combinator, you can select an element based on what follows it — a previous-sibling selector, something CSS never had before:
/* Select an h2 that is immediately followed by a subtitle paragraph */
h2:has(+ .subtitle) {
margin-bottom: 0.25rem;
}
/* Select an input that is followed by an error message */
input:has(+ .error-message) {
border-color: #cc0000;
}
The first example selects the h2 — not the subtitle — when a .subtitle element immediately follows it. This lets you reduce the bottom margin of the heading because you know a subtitle is coming next. Before :has(), you would need JavaScript or a wrapper element to achieve this.
General Sibling Combinator (~)
Similar to the adjacent sibling combinator, but matches any subsequent sibling rather than only the immediately next one:
/* Select a label that has any subsequent sibling with class .required */
label:has(~ .required) {
font-weight: bold;
}
Form Validation Patterns
Forms are one of the most impactful areas for :has(). You can style form groups, labels, and wrappers based on the state of their input descendants, without any JavaScript.
/* Highlight a form group when its input is focused */
.form-group:has(input:focus) {
background-color: #f0f7ff;
border-left: 3px solid #0066cc;
padding-left: 1rem;
}
/* Style a form group containing an invalid input */
.form-group:has(input:invalid) {
border-left: 3px solid #cc0000;
}
/* Show helper text only when the input is focused */
.form-group:has(input:focus) .helper-text {
display: block;
}
/* Style the label when its associated input has content (using :not(:placeholder-shown)) */
.form-group:has(input:not(:placeholder-shown)) label {
color: #0066cc;
font-size: 0.75rem;
transform: translateY(-1.5rem);
}
The last example creates a floating label effect entirely in CSS. When the input has content (detected via :not(:placeholder-shown)), the parent .form-group matches, and the label is restyled. This pattern previously required JavaScript event listeners.
Complete Form Example
<form>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" required placeholder="you@example.com">
<span class="error">Please enter a valid email address.</span>
</div>
</form>
<style>
.field {
position: relative;
padding: 0.5rem;
margin-bottom: 1rem;
border: 2px solid transparent;
border-radius: 4px;
transition: border-color 0.2s, background-color 0.2s;
}
.error {
display: none;
color: #cc0000;
font-size: 0.85rem;
margin-top: 0.25rem;
}
/* When the field contains a focused input */
.field:has(input:focus) {
border-color: #0066cc;
background-color: #fafcff;
}
/* When the field has an invalid, non-placeholder-shown input (user has typed something invalid) */
.field:has(input:invalid:not(:placeholder-shown):not(:focus)) {
border-color: #cc0000;
background-color: #fff5f5;
}
.field:has(input:invalid:not(:placeholder-shown):not(:focus)) .error {
display: block;
}
/* When the field has a valid input with content */
.field:has(input:valid:not(:placeholder-shown)) {
border-color: #00884b;
}
</style>
This form provides visual feedback for focus, valid, and invalid states without a single line of JavaScript. The error message only appears after the user has interacted with the field and moved focus away, avoiding the jarring experience of showing errors before the user has finished typing.
Quantity Queries with :has()
Quantity queries — styling a list differently based on how many items it contains — were once a clever hack involving :nth-child and sibling selectors. With :has(), they become straightforward and readable.
/* A list with 5 or more items */
ul:has(li:nth-child(n + 5)) {
columns: 2;
column-gap: 2rem;
}
/* A list with fewer than 5 items (does NOT have a 5th child) */
ul:not(:has(li:nth-child(5))) {
list-style: disc;
}
/* A grid that has 7 or more children */
.grid:has(> :nth-child(n + 7)) {
grid-template-columns: repeat(4, 1fr);
}
/* A grid with fewer than 4 children */
.grid:not(:has(> :nth-child(4))) {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
The key insight is that li:nth-child(n + 5) matches the 5th, 6th, 7th (and so on) list items. If any such item exists, the ul:has() condition is true. This effectively means "the list has at least 5 items." Combined with :not(:has()), you can express "fewer than N items" cleanly.
Styling Based on State: Checkbox and Toggle Patterns
The :has() selector unlocks powerful state-driven styling via checkbox and radio inputs:
/* Dark mode toggle without JavaScript */
body:has(#dark-mode-toggle:checked) {
--bg: #1a1a2e;
--text: #e0e0e0;
--surface: #16213e;
background-color: var(--bg);
color: var(--text);
}
/* Accordion panel: show content when checkbox is checked */
.accordion-item:has(input[type="checkbox"]:checked) .accordion-content {
display: block;
max-height: 500px;
}
/* Tab system: highlight active tab */
.tabs:has(#tab-2:checked) .tab-label[for="tab-2"] {
border-bottom: 3px solid #0066cc;
color: #0066cc;
}
.tabs:has(#tab-2:checked) #panel-2 {
display: block;
}
The dark mode toggle example is particularly striking. A single checkbox in the body can flip the entire page theme through pure CSS. The body:has(#dark-mode-toggle:checked) selector matches the body element when a checkbox with that specific ID is checked, regardless of where in the DOM tree the checkbox lives.
Selecting Based on Empty States
You can use :has() with :empty or combine it with negation to handle empty containers:
/* Hide a section wrapper when its content area is empty */
.section-wrapper:has(.content:empty) {
display: none;
}
/* Show a placeholder message when a list has no items */
.todo-list:not(:has(li)) .empty-state {
display: flex;
}
.todo-list:has(li) .empty-state {
display: none;
}
Combining :has() with Other Selectors
The :has() pseudo-class composes naturally with other selectors and pseudo-classes:
/* A card that contains an image AND a badge */
.card:has(img):has(.badge) {
position: relative;
}
/* A nav link that is .active and has a submenu */
.nav-link.active:has(+ .submenu) {
border-bottom: none;
border-radius: 4px 4px 0 0;
}
/* Any element that does NOT contain a link */
.card:not(:has(a)) {
cursor: default;
}
Multiple :has() conditions act as logical AND — all conditions must be satisfied. The :not(:has()) pattern gives you negation. You can also use a comma-separated list inside :has() for logical OR: :has(img, video) matches elements containing either an image or a video.
Performance Considerations and Anti-Patterns
The :has() pseudo-class is marked as a "forgiving selector list," meaning invalid selectors inside it are ignored rather than invalidating the entire rule. This is helpful for forward compatibility but can mask typos.
Performance-wise, browser teams have invested heavily in making :has() fast. Modern engines use Bloom filters and ancestor marking to avoid full document traversals. However, some patterns are more expensive than others:
- Broad descendant checks are fine:
div:has(img)is well-optimized and performs similarly to regular descendant selectors during invalidation. - Sibling combinators are efficient:
h2:has(+ p)only needs to check the immediately adjacent element. - Deep nesting can be slower:
body:has(.deeply-nested .specific .chain)forces the browser to search a larger subtree. Prefer shallower selectors when possible. - Avoid combining with universal selectors:
*:has(img)is legal but expensive because it tests every element in the document. Always qualify your:has()with a more specific base selector.
As a general rule, if you would not write the equivalent descendant selector in normal CSS due to performance concerns, do not put it inside :has() either. Keep your arguments reasonably specific and your subject selectors well-scoped.
A Note on Specificity
The specificity of :has() is calculated based on the most specific argument in its selector list. For example, :has(.card) contributes the specificity of a class selector (0, 1, 0), while :has(#main) contributes the specificity of an ID selector (1, 0, 0). This matches the behavior of :is() and :not().
Real-World Use Cases
Beyond the patterns already discussed, here are several production-ready applications:
- Navigation highlighting: Style a navigation bar differently when it contains an active dropdown —
nav:has(.dropdown.open). - Content layout adaptation: Switch an article layout to include a sidebar column when a table of contents element is present —
article:has(.toc). - Conditional spacing: Remove bottom margin from the last heading before a specific type of content —
h2:has(+ .code-block). - Image galleries: Change the grid layout of a gallery based on whether it contains portrait or landscape images —
.gallery:has(img[data-orientation="portrait"]). - Alert dismissal: Hide a notification banner when it contains a checkbox the user has checked —
.banner:has(.dismiss:checked) { display: none; }.
Browser Support
The :has() pseudo-class is supported in all modern evergreen browsers:
- Chrome — 105+ (released August 2022)
- Edge — 105+ (same Chromium base)
- Safari — 15.4+ (released March 2022, the first browser to ship it)
- Firefox — 121+ (released December 2023)
Safari was actually the first browser to implement :has(), which is notable given its reputation for lagging on CSS features. Firefox was the last major browser to add support, shipping it in version 121 in late 2023. As of 2026, global browser support exceeds 93% according to Can I use, and :has() reached Baseline Widely Available in 2024 — making it safe for production use with a simple progressive-enhancement fallback.
For fallbacks, you can use @supports selector(:has(img)) to feature-detect support and provide alternative styles for older browsers:
/* Fallback for browsers without :has() */
.form-group {
border-left: 3px solid transparent;
}
/* Enhanced styles for browsers with :has() */
@supports selector(:has(img)) {
.form-group:has(input:focus) {
border-left-color: #0066cc;
}
}
Frequently Asked Questions
What is the CSS :has() selector?
:has() is a relational pseudo-class that matches an element if it contains at least one descendant or sibling matching the selector inside its parentheses. It is commonly called the parent selector because article:has(img) matches the article when an image lives inside it.
Is the CSS parent selector finally supported?
Yes. :has() is supported in Safari 15.4+, Chrome and Edge 105+, and Firefox 121+. Global support is above 93% as of 2026 and the feature is in Baseline Widely Available.
Is :has() slow or bad for performance?
Modern engines use Bloom filters and ancestor invalidation to make :has() roughly as fast as regular descendant selectors in practice. Avoid pairing it with the universal selector and keep arguments shallow; otherwise it performs well.
How do I select a previous sibling with :has()?
Use the adjacent sibling combinator inside :has(): h2:has(+ .subtitle) selects the h2 that is immediately followed by a .subtitle. Use ~ for any later sibling.
Can :has() replace JavaScript for forms and toggles?
In many cases, yes. :has(input:invalid), :has(input:focus), and :has(input:checked) let a wrapper react to descendant state, so floating labels, validation styles, accordions, and even page-level dark-mode toggles can be built without JavaScript event listeners.
How is specificity calculated for :has()?
The specificity of :has() equals the highest-specificity selector inside its argument list, just like :is() and :not(). :has(.card) contributes (0,1,0); :has(#main) contributes (1,0,0).
Further Reading
The best way to internalize :has() is to experiment with it. Try our interactive :has() Selector Tester tool to build and visualize :has() selectors against live HTML. You can test combinators, pseudo-classes, and nesting in real time and see exactly which elements match.
External references: MDN — :has() · W3C — CSS Selectors Level 4 (relational) · web.dev — The :has() parent selector.
For more CSS guides and tools, visit the guides index or browse the full Modern CSS Tools collection.