Parent Selection: The Obvious Use Case
The canonical example, and still the most common use case:
/* Style the card container when it contains an image */
.card:has(> img) {
grid-template-rows: 200px 1fr;
}
.card:not(:has(> img)) {
grid-template-rows: 1fr;
}
Before :has(), this required either two card variants in your component library (.card--with-image, .card--text-only) or JavaScript that checked for the image and added a class. Both are maintenance burdens. The CSS solution reacts to the actual content.
A more practical example, a form where the submit button container changes layout based on whether validation errors exist:
.form-actions:has(~ .error-message) {
flex-direction: column;
gap: 0.5rem;
}
.form-actions:has(~ .error-message)::before {
content: "Please fix the errors above";
color: var(--color-error);
font-size: 0.875rem;
}
The ~ general sibling combinator inside :has() is what makes this work, the form actions element checks if any subsequent sibling is an error message. No JavaScript. No state management. Pure CSS responding to DOM structure.
Quantity Queries: Native CSS Implementation
Quantity queries let you style a container differently based on how many children it has. This was a solved problem in Sass with mixins that generated :nth-child + :nth-last-child combinations, but :has() makes it native and readable.
/* Single item, full width */
.grid:has(> :nth-child(1):last-child) {
grid-template-columns: 1fr;
}
/* 2 items, two columns */
.grid:has(> :nth-child(2):last-child) {
grid-template-columns: repeat(2, 1fr);
}
/* 3 items, three columns */
.grid:has(> :nth-child(3):last-child) {
grid-template-columns: repeat(3, 1fr);
}
/* 4+ items, auto-fill grid */
.grid:has(> :nth-child(4)) {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
The trick is the :nth-child(n):last-child pattern. :nth-child(3):last-child matches an element that is both the third child AND the last child, meaning the container has exactly 3 children.
This pattern works well on dashboards where the number of KPI cards varies per user role. Previously, React components calculated a columns prop and passed it as a CSS variable. Now the CSS handles it autonomously:
.kpi-dashboard:has(> .kpi-card:nth-child(5)) {
/* 5+ cards: switch to compact layout */
--kpi-gap: 0.75rem;
--kpi-font-size: 0.875rem;
}
.kpi-dashboard:has(> .kpi-card:nth-child(5)) .kpi-card {
padding: 0.75rem;
}
Range Queries
You can also target ranges, "between 3 and 6 items":
/* Has at least 3 children AND the 7th child does NOT exist */
.list:has(> :nth-child(3)):not(:has(> :nth-child(7))) {
/* 3-6 items */
columns: 2;
}
This reads as: "a .list that has a 3rd child (so at least 3) but doesn't have a 7th child (so at most 6)." It's verbose, but it works without a preprocessor.
Sibling-Aware Styling
:has() combined with sibling combinators lets you style elements based on what comes after them. This was completely impossible before.
/* Style a heading differently when followed by a subtitle */
h2:has(+ .subtitle) {
margin-bottom: 0.25rem;
}
h2:not(:has(+ .subtitle)) {
margin-bottom: 1.5rem;
}
/* Style the last item before a divider */
.nav-item:has(+ .nav-divider) {
margin-bottom: 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
This pattern proves valuable in navigation redesigns. The old approach requires .nav-item--before-divider classes generated by the template engine. The :has() version can eliminate dozens of lines of template logic.
Form State Cascading
This is where :has() starts to feel like it's replacing JavaScript wholesale. You can cascade form element states up to any ancestor:
/* Entire form section gets visual feedback based on validity */
.form-section:has(:invalid) {
border-left: 3px solid var(--color-error);
}
.form-section:has(:focus-within) {
background: var(--bg-active);
}
/* Fieldset label changes when any child input has content */
.field-group:has(input:not(:placeholder-shown)) label {
transform: translateY(-1.5rem) scale(0.85);
color: var(--color-primary);
}
/* Disable submit when form has invalid fields */
form:has(:invalid) button[type="submit"] {
opacity: 0.5;
pointer-events: none;
}
That last one, disabling submit based on form validity, used to require a useEffect hook watching every form field. Now it's one CSS rule.
The Checkbox Hack, Evolved
The classic checkbox hack (:checked + ~ sibling combinator) was limited to styling siblings. With :has(), you can style anything in the document based on checkbox state:
/* Dark mode toggle, zero JavaScript */
html:has(#theme-toggle:checked) {
--bg: #1a1816;
--text: #e8e6e3;
--accent: #d97757;
color-scheme: dark;
}
/* Sidebar collapse */
.layout:has(#sidebar-toggle:checked) .sidebar {
transform: translateX(-100%);
}
.layout:has(#sidebar-toggle:checked) .main-content {
grid-column: 1 / -1;
}
This is not an argument for replacing all JavaScript interactivity with checkbox hacks. But for binary toggles that affect purely visual state, this approach is cleaner than a React context provider.
Responsive Patterns Enabled by :has()
A notable pattern, responsive layout that adapts to content, not viewport:
/* Container with optional sidebar */
.page-layout {
display: grid;
grid-template-columns: 1fr;
}
.page-layout:has(> .sidebar) {
grid-template-columns: 1fr 300px;
}
/* But collapse to single column if the sidebar is empty */
.page-layout:has(> .sidebar:empty) {
grid-template-columns: 1fr;
}
/* And switch based on content density */
.page-layout:has(> .sidebar > :nth-child(5)) {
grid-template-columns: 1fr 400px;
/* Wider sidebar when it has 5+ items */
}
This responds to the actual content of the sidebar, not a prop, not a class, not a media query. The layout is emergent from the content structure.
Another one, conditional table styling based on column count:
/* Compact table when it has many columns */
table:has(th:nth-child(8)) {
font-size: 0.75rem;
}
table:has(th:nth-child(8)) th,
table:has(th:nth-child(8)) td {
padding: 0.25rem 0.5rem;
}
/* Highlight row on hover, but only if table has interactive column */
table:has(th.actions) tbody tr:hover {
background: var(--row-hover);
cursor: pointer;
}
Performance Considerations
Now for the part that matters at scale. :has() is a relational pseudo-class, the browser has to evaluate the argument selector for each element matching the subject. This has real costs.
Profiling a page with 2,000 DOM nodes and various :has() selectors yields these results from Chrome DevTools Selector Stats:
.card:has(> img) → 0.3ms (fast, direct child)
.card:has(img) → 1.8ms (slow, descendant search)
.grid:has(:nth-child(4):last-child) → 0.4ms (fast, structural)
.page:has(.error) → 3.2ms (slow, deep descendant)
.a:has(.b:has(.c)) → 8.7ms (avoid this)
The rules of thumb:
-
Use direct child combinators (
>) whenever possible. The difference between:has(> img)and:has(img)is roughly 6x in benchmarks. -
Avoid
:has()on frequently-updated subtrees. If a DOM node changes often (virtual lists, real-time feeds), every mutation triggers:has()re-evaluation for all ancestors. -
Don't combine
:has()with*. The selector.parent:has(* > .child)is pathological, it walks the entire subtree at every level. -
Profile before optimizing. In most real applications, CSS selector matching is a rounding error compared to layout and paint. Don't avoid
:has()out of unfounded fear.
/* GOOD, constrained scope */
.card:has(> .badge) { /* ... */ }
/* BAD, unconstrained descendant search */
.page:has(.badge) { /* ... */ }
/* TERRIBLE, nested :has() with descendant search */
.page:has(.section:has(.card:has(.badge))) { /* ... */ }
Progressive Enhancement Strategy
For the rare case where you need to support browsers without :has():
/* Base styles, work everywhere */
.card {
grid-template-rows: 1fr;
}
/* Enhanced styles, only in :has()-capable browsers */
@supports selector(:has(*)) {
.card:has(> img) {
grid-template-rows: 200px 1fr;
}
}
The @supports selector() query is itself well-supported at this point, making this pattern safe.
Wrapping Up
:has() is the most significant CSS feature since Flexbox. That's not hyperbole, it fundamentally changes the relationship between CSS and DOM structure. Patterns that required JavaScript (parent selection), preprocessors (quantity queries), or were simply impossible (sibling-forward awareness) are now native CSS.
Production use of :has() since mid-2024 has demonstrated measurable benefits. Codebases that adopt it can reduce JavaScript bundles by 10-15KB by eliminating DOM-querying code that now lives in CSS. The selectors are readable, the performance is acceptable, and the maintenance burden is dramatically lower.
The CSS should describe the relationship between your elements. Let the browser figure out the rest.