The :has() Selector Changes Everything: Real-World Patterns
CSS avoided parent selection for years over performance fears. WebKit broke the deadlock with Safari 15.4; here are the real-world :has() patterns worth using in production today.
Paul Radford — Full-Stack Developer & Editor. Paul is a full-stack developer and editor of template.tips. Articles here are AI-drafted and reviewed by Paul, who knows the code well enough to catch what's wrong and cut the hype.

The :has() pseudo-class lets you style a parent element based on what it contains, or style an element based on what follows it, something CSS could never do without JavaScript. It flips the selection model from "descendant matches ancestor" to "ancestor matches based on descendant," and it now works in every major shipping browser.
Why did CSS avoid this for so long?
CSS selectors were historically designed to be fast to evaluate in a single left-to-right (or right-to-left, depending on the engine) pass. A selector that asks "does this element contain a child matching X" forces the browser to look forward and potentially re-check large subtrees whenever the DOM changes. Browser vendors worried about performance regressions on large pages, which is why :has() sat in the CSS spec for years as a theoretical feature before anyone shipped it.
WebKit broke the deadlock. Safari 15.4 shipped :has() in March 2022, and Chrome followed in Chrome 105 (August 2022). Firefox was the last major holdout, landing support in Firefox 121 in December 2023. As of 2026, you can use :has() in production without a fallback for the vast majority of traffic, though you should still check caniuse if you support older enterprise browsers or WebViews pinned to older Chromium builds.
The pattern everyone reaches for first: styling a parent based on its children
The classic example is a form group that needs an error state when it contains an invalid input. Before :has(), this required a JavaScript class toggle on blur or input.
1/* Style the entire form row when it contains an invalid field.2 No JavaScript needed to add or remove an error class. */3.form-row: has(input:invalid) {4 border-left: 3px solid #d33;5 background-color: #fdecea;6}78/* Style a card differently if it contains an unread badge. */9.card: has(.badge--unread) {10 box-shadow: 0 0 0 2px #2563eb;11}This is genuinely a different way of thinking about CSS architecture. You stop writing JavaScript solely to sync visual state with DOM structure, because the DOM structure already contains the information you need. The caveat: :has(input:invalid) fires as soon as the browser considers the field invalid, which for required fields means before the user has typed anything. Pair it with :user-invalid (not yet universally supported) or scope validation styling to a .was-submitted class you toggle once, combined with :has(), so users are not shown red borders on page load.
Can you use :has() to select a previous sibling?
Yes, and this is the pattern that gets the most excitement because CSS has never had a "previous sibling" selector. :has() combined with :has(+ selector) inside a parent context, or more directly, A:has(~ B), lets you style an element based on what comes after it in the DOM.
1/* Highlight a label when the checkbox that follows it is checked.2 Works because : has() looks inside the current element's3 subsequent siblings via the ~ combinator. */4.option: has(~ .option input:checked) {5 opacity: 0.6;6}78/* A more common real case: dim all previous list items when9 any later item is marked "active", useful for step indicators. */10.step: has(~ .step.is-active) {11 color: #9ca3af;12}This is not literally a "previous sibling selector" in the sense of A:prev(B). You are still writing A:has(~ B), meaning "select A if A has a later sibling B." The mental model that trips people up: the subject of the selector stays on the left, :has() just changes what counts as a match. Once that clicks, sibling-based layouts (progress steppers, "active row highlights the row above," accordion state) become far simpler to write.
Container queries versus :has(): which one do you actually need?
These get confused because both let a parent react to something inside it, but they solve different problems. Container queries (@container) let an element respond to the size of an ancestor. :has() lets an element respond to the presence or state of a descendant. If you are asking "should this card switch to a two-column layout because its container got wider," that is a container query. If you are asking "should this card change its border because it contains an image," that is :has().
They compose well together:
1.card {2 container-type: inline-size;3}45/* Container query: react to available width. */6@container (min-width: 400px) {7 .card {8 display: grid;9 grid-template-columns: 120px 1fr;10 }11}1213/* : has() query: react to content. Combine both conditions14 by nesting or by chaining selectors as needed. */15.card: has(img) {16 grid-template-areas: "media body";17}Do not reach for :has() as a substitute for a size-based layout system. I have seen developers try to detect "is this sidebar narrow" by checking for the presence of a class that JavaScript adds after a ResizeObserver callback, when a native container query does the same thing with zero JavaScript and no layout thrashing.
Where :has() gets genuinely dangerous: performance and specificity
:has() has zero specificity cost by itself, in the sense that it does not add a specificity tier the way an ID or attribute selector does, but the specificity of whatever you put inside the parentheses counts fully. :has(.badge) carries the specificity of a class selector. :has(#unique-id) carries the specificity of an ID. This surprises people who assume :has() is "free."
Performance is the bigger concern in real codebases. Browsers have optimized :has() reasonably well since launch, but a selector like body:has(.modal-open) *:has(img) forces the browser to evaluate a relational check against a huge number of elements on every relevant DOM mutation. The CSS Selectors Level 4 spec defines :has() without a performance guarantee, and it is genuinely engine-dependent. Chromium's own engineers have published notes on how they cache and invalidate :has() matches, but the safe practice is:
- Scope the argument as narrowly as possible. :has(> .child) (direct child) is cheaper to invalidate than :has(.descendant) (any depth).
- Avoid :has() on the html or body element combined with a broad descendant selector if you can scope it to a smaller container instead.
- Test on your actual DOM size, not a toy page. A :has() selector that feels instant on a 50-node demo can visibly jank on a 5,000-node data table.
A real gotcha: :has() and the :not() interaction
One combination that catches experienced developers off guard is using :has() inside :not(), or the reverse, to express "this element does NOT contain X."
1/* Select cards that do not contain an image, to apply a2 placeholder background. This is the pattern that finally3 makes "style an empty state" possible without JS. */4.card: not(:has(img)) {5 background: repeating-linear-gradient(6 45deg, #f3f4f6, #f3f4f6 10px, #e5e7eb 10px, #e5e7eb 20px7 );8}This works reliably in current browsers, but it is worth testing explicitly in your target matrix rather than assuming it, because early WebKit implementations had bugs around negation combined with relational selectors that were fixed over subsequent point releases. If you support Safari versions from 2022, verify this specific combination rather than trusting general :has() support tables.
How to actually verify support before you ship this
Do not rely on "I think it's supported now" for a selector this structurally important. Check three things before you commit :has() to a production stylesheet without a fallback:
First, confirm your real traffic's browser floor. Pull your analytics for the last 90 days and check the minimum browser versions actually visiting your site, then cross-reference against the MDN compatibility table for :has(), which lists exact version numbers per engine rather than a simple yes/no.
Second, write a @supports selector(:has(a)) feature query around any layout-critical use, so browsers that lack support get a graceful fallback instead of a broken layout:
1@supports selector(: has(a)) {2 .nav-item: has(> a[aria-current="page"]) {3 font-weight: 700;4 }5}Third, test the specific combinator you are using, not just :has() in isolation. :has() with a direct child (>), with a general sibling (~), and with :not() nested inside have shipped at slightly different times across engines, and a selector that works in isolation can fail when combined with :nth-child() or :first-of-type inside the parentheses in older point releases.
If you are building something that must work identically across a wide install base right now, keep the JavaScript class-toggling approach as a fallback behind the feature query rather than removing it outright. If your audience skews toward current browsers (most SaaS dashboards, internal tools, and anything behind a login screen), :has() is safe to use as your primary mechanism today, and the reduction in JavaScript-driven class toggling is worth the migration.
Related Articles

CSS @when/@else: Writing Conditional Styles Without JavaScript
Testing three separate conditions today means three conditional blocks stitched together, or a JavaScript fallback filling the gaps. @when/@else isn't shipping yet, but it's worth planning for.

CSS @scope: Scoped Styles Without Frameworks
A .card img rule quietly matching an unrelated nested image six months later is a familiar CSS failure mode. @scope gives you a real containment boundary instead of a naming convention.

CSS Cascade Layers: Finally Tame Your Specificity Wars
Specificity wars used to mean raising your own selector weight or reaching for !important as a last resort. Cascade layers add a priority axis that sits above specificity entirely.