The CSS :has() Selector: A Practical Guide
How the :has() pseudo-class finally gives CSS a parent selector, and the patterns, pitfalls and performance trade-offs that come with it.

For two decades the running joke in CSS was that there's no parent selector. You could style a child based on its parent, but never the reverse — "style this .card if it contains an img" required JavaScript, a class toggle, or a workaround that didn't actually work. The :has() pseudo-class, now shipped in every evergreen browser, finally closes that gap. It's not a parent selector in the literal sense — it's a relational pseudo-class — but in practice it does exactly what engineers have wanted since CSS2: let the DOM's structure drive styling decisions without touching JavaScript.
The catch is that :has() is powerful enough to misuse. It reads like a simple "does this contain that" check, but it's actually a full selector evaluated relationally, and that has real implications for both your CSS architecture and your render performance.
What :has() Actually Does
:has() matches an element if at least one selector passed to it matches something relative to that element — a descendant, a sibling, or a direct child. The MDN documentation for :has() calls it a "relational pseudo-class" for exactly this reason: it doesn't select the thing inside the parentheses, it selects the subject based on what's inside.
/* Style a card differently if it contains an image */
.card:has(img) {
display: grid;
grid-template-columns: 120px 1fr;
}
/* Style a form group when its input is invalid */
.form-group:has(input:invalid) {
border-left: 3px solid var(--color-danger);
}
/* Style a label based on a sibling checkbox's state */
.toggle-row:has(input[type="checkbox"]:checked) {
background: var(--color-surface-active);
}Notice the third example: :has() isn't limited to descendants. Combined with the general sibling combinator ~, it lets you react to a sibling's state — something that was previously impossible without reordering markup to abuse the "checkbox hack."
Real-World Patterns
The pattern that shows up most often in production code is conditional layout based on content count. Before :has(), this meant a JavaScript ResizeObserver or a server-rendered class based on array length.
/* ❌ Bad: JavaScript just to add a layout class */
const grid = document.querySelector('.card-grid');
if (grid.children.length > 4) {
grid.classList.add('dense-layout');
}/* ✅ Good: pure CSS, no observer, no re-render */
.card-grid:has(> .card:nth-child(5)) {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}The second pattern is validation-aware styling without a validation library touching your DOM classes:
/* Highlight the whole fieldset if any required field is empty */
fieldset:has(input:required:placeholder-shown) {
outline: 2px dashed var(--color-warning);
}
/* Disable a submit button declaratively when a form is invalid */
button[type="submit"]:has(+ .form-errors:not(:empty)) {
pointer-events: none;
opacity: 0.5;
}A third common use case is theming scoped to content, where a component needs to know whether it has a specific child without a build-time or runtime class:
/* Give articles with a hero image more generous top spacing */
article:has(> .hero-image) {
padding-block-start: 0;
}
article:not(:has(> .hero-image)) {
padding-block-start: 2rem;
}That last pair — :has() combined with :not() — is where the pseudo-class starts replacing entire classes of conditional rendering logic that used to live in your component code.
Performance Considerations
:has() is not free. Because it can match based on descendants, a naive implementation would force the browser to walk the entire subtree of every candidate element on every style recalculation. Browser vendors mitigated this with internal optimizations — tracking which elements are "has-anchors" and invalidating styles incrementally rather than re-scanning the DOM — but the cost model is still different from a plain descendant selector.
A few rules of thumb that matter in real codebases:
- Scope the right side of
:has()as tightly as possible..card:has(img)is cheap..card:has(* img)or deeply nested selectors inside:has()cost more because the browser has to check more of the subtree. - Avoid
:has()on elements that re-render constantly, like list items in a virtualized scroll container, where every mutation can trigger a relational recheck. - Prefer
:has()over aMutationObserveronly when the check is simple. If you need to react to complex state changes (API responses, computed values), JavaScript-driven classes are still the right tool —:has()solves structural and state-based conditions, not business logic.
/* ❌ Expensive: unbounded descendant search */
.sidebar:has(.nav-item.active .submenu a:hover) {
box-shadow: inset 4px 0 0 var(--color-accent);
}
/* ✅ Cheap: shallow, direct relationship */
.sidebar:has(> .nav-item.active) {
box-shadow: inset 4px 0 0 var(--color-accent);
}If you're auditing an existing stylesheet for :has() performance, check DevTools' "Style recalculation" timing in the Performance panel before and after adding a relational selector — the cost is often negligible until the subtree gets deep or the element count is large.
Browser Support and Progressive Enhancement
:has() is defined in CSS Selectors Level 4, and as of this writing it's supported in all major evergreen browsers — Chromium-based browsers, Safari, and Firefox. That said, "evergreen" doesn't mean "every user's browser," especially if you support enterprise environments running older installs.
The safe pattern is feature detection with @supports, which works because :has() was designed to be detectable:
@supports selector(:has(a)) {
.card:has(img) {
grid-template-columns: 120px 1fr;
}
}
@supports not selector(:has(a)) {
/* Fallback: always show the two-column layout */
.card {
grid-template-columns: 120px 1fr;
}
}This gives you a layout that degrades gracefully instead of silently failing. If your fallback requires more than a layout tweak — say, disabling a submit button — keep the JavaScript-based check in place as a safety net rather than relying on CSS alone for anything that affects form correctness.
Combining :has() with Other Selectors
The real power shows up when :has() is composed with other Selectors Level 4 features, particularly :not() and container queries. A pattern I reach for often: styling a layout based on both its content and its available space.
.content-area {
container-type: inline-size;
}
/* Only widen the sidebar gap if the container is roomy
AND the sidebar actually has nested navigation */
@container (min-width: 640px) {
.layout:has(.sidebar .submenu) {
grid-template-columns: 280px 1fr;
}
}This kind of composition — structural awareness (:has()) plus spatial awareness (container queries) — is what lets component CSS become genuinely self-contained, without a parent component needing to pass down layout props just to accommodate a child's presence.
Key Takeaways
:has()is a relational pseudo-class, not a literal parent selector — it matches the subject based on descendants, children, or siblings you specify inside the parentheses.- Use it to replace JavaScript that only exists to toggle a class based on DOM structure or form state — that's its strongest use case.
- Keep the selector inside
:has()as shallow as possible; unbounded descendant checks carry a real style-recalculation cost. - Wrap non-critical uses in
@supports selector(:has(a))and keep JavaScript fallbacks for anything affecting form correctness or accessibility. - Combine
:has()with:not()and container queries to build components that react to both their content and their available space without prop drilling. - Audit any
:has()addition to a frequently-mutating subtree (virtualized lists, live feeds) with the DevTools Performance panel before shipping it.


