Back to Lab
RAXXO Studios 8 min read No time? Make it a 1 min read

The CSS :has() Patterns That Changed How I Write UI

CSS
8 min read
TLDR
×
  • CSS :has() hit full browser support in late 2023 and works in every modern browser in 2026
  • I use six concrete :has() patterns across raxxo.shop Liquid templates, replacing roughly 400 lines of JavaScript
  • Quantity queries with :has(> :nth-child(n)) let grids respond to item count without media queries
  • :has() cannot be nested inside another :has(), and it still triggers style recalc on large DOMs

I ignored CSS :has() for almost two years. It landed in Safari first, then Chrome, and by December 2023 every evergreen browser shipped it. I kept writing the same JavaScript I had been writing since 2015. Adding a class to a parent when a child changed. Toggling a body class when a modal opened. Listening for input events to paint a form red. Then one afternoon I deleted about 40 lines of JS from a single Liquid section and replaced it with four lines of CSS. That was the moment. Since then I have been quietly pulling JavaScript out of raxxo.shop and replacing it with `:has()` selectors. Six patterns cover maybe 80 percent of what I reach for. This is the concrete, paste-able version.

Why CSS :has() is the feature I keep rediscovering

Browser support is done. caniuse shows 94 percent global support as of April 2026. The holdouts are mostly older Android WebViews and one stubborn corner of Samsung Internet. If you were waiting for the green bar, it has been green for a while.

The cascade implication is the part that messed with my head at first. `:has()` is a forgiving relational pseudo-class, which means it reads forward in the DOM. You can style a parent based on its descendants. CSS has always flowed downward. This reverses the arrow. Once you accept that, half your JavaScript state management stops making sense.

Performance is the thing people warn about. In practice I have never measured a `:has()` selector that hurt. The engines are smart about scoping. Where I have seen it matter is on tables with 5000 rows where every row contains a `:has(input:checked)` style. Do not use it for that. For everything else, it is fine. The Chromium team published benchmarks in 2024 showing less than 0.2ms recalc overhead for typical page sizes.

One caveat before the patterns. `:has()` is not allowed inside another `:has()`. You cannot write `:has(:has(...))`. You can nest other pseudo-classes inside it, including `:not()`, but not recursive `:has()`. The spec forbids it for complexity reasons. I hit this limit maybe twice a year.

The six CSS :has() patterns I actually ship

1. Parent-has-invalid-child

The form container turns red when any input inside it is invalid. No JS, no event listener, no form library.


form:has(input:invalid) {
  border-color: #ff3b30;
  background: rgba(255, 59, 48, 0.04);
}

form:has(input:invalid) button[type="submit"] {
  opacity: 0.4;
  pointer-events: none;
}

I use this on the raxxo.shop newsletter block in `sections/newsletter.liquid`. The submit button dims automatically when the email field is empty or malformed. Previously this was a 30-line IIFE that bound to input events.

2. Sibling sensing without JavaScript

A card that reacts to its label being hovered, or a tooltip that shows when a trigger is focused. Classic use case, and `:has()` combined with sibling combinators makes it trivial.


.card:has(~ .card-label:hover) {
  transform: translateY(-2px);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}

.trigger:has(+ .tooltip-target:focus-visible) {
  outline: 2px solid #e3fc02;
}

In the product grid on raxxo.shop, hovering the little "view details" pill lifts the card above it. The card does not need a JS listener or a parent wrapper with state. The selector reads right to left in the browser engine but left to right for humans, which is fine.

3. Empty state detection

Conditional headings. A section title that only appears when there are actual items below it. Before `:has()` this was a Liquid conditional. Now it is pure CSS.


.recently-viewed:not(:has(> .product-card)) {
  display: none;
}

.filter-group:has(> .filter-option) .group-heading {
  display: block;
}

This lives in the "recently viewed" section. If the customer has no history yet, the whole block disappears including its heading and divider. The server does not need to know. The Liquid template renders unconditionally and CSS hides it when empty.

4. Container-aware components

A navigation bar that hides itself when it has no visible children. A sidebar that collapses when it is empty. Components that know their own contents.


nav.secondary:not(:has(> a:not([hidden]))) {
  display: none;
}

.sidebar:has(> .widget) {
  padding-inline: 16px;
}

I have this on the collection page where a secondary nav shows only when filters are active. The filters toggle `[hidden]` on themselves based on the URL. When all are hidden, the nav vanishes. No MutationObserver, no framework, no useEffect.

5. Quantity queries with :nth-child

This is the one that made me actually yell at my monitor the first time I got it working. A grid that changes its column count based on how many items it contains. Not based on viewport width. Based on item count.


.product-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 16px;
}

.product-grid:has(> :nth-child(2)) { grid-template-columns: repeat(2, 1fr); }
.product-grid:has(> :nth-child(3)) { grid-template-columns: repeat(3, 1fr); }
.product-grid:has(> :nth-child(4)) { grid-template-columns: repeat(4, 1fr); }

One item, full width. Two items, two columns. Three or more, three columns and it wraps naturally. The related-products carousel on raxxo.shop uses this. Some products have two related items, some have six. The grid just handles it. No JavaScript counting children, no Liquid math block.

You can combine this with `@media` if you want. Desktop gets the quantity logic, mobile stays single column. That is where quantity queries really click. They are orthogonal to media queries, not a replacement.

6. Body scroll lock when a modal opens

The native `

` element finally has real support, and pairing it with `:has()` gives you free scroll lock.


body:has(dialog[open]) {
  overflow: hidden;
}

body:has(dialog[open])::before {
  content: "";
  position: fixed;
  inset: 0;
  background: rgba(31, 31, 33, 0.7);
  z-index: 10;
}

That is the entire scroll lock logic. No `document.body.classList.add('modal-open')`. No scroll position restoration math. When the dialog closes, the overflow comes back. The color on the overlay uses the site background at 70 percent opacity so it feels like the page dimmed rather than a generic black wash. Text color in the dialog stays #F5F5F7 as always.

Gotchas I hit and how I dealt with them

Specificity trips people up. `:has()` takes the specificity of its most specific argument. So `.card:has(.badge.featured)` is more specific than `.card.active`. This matters when you are layering rules. Write your `:has()` selectors early in the cascade, or accept that they will win fights you did not expect them to.

Performance on large DOMs is real but narrow. If you put a `:has()` selector on `html` or `body` that matches something deep and frequently updating, you will see recalc cost. The fix is almost always to scope the selector tighter. Instead of `body:has(.some-deep-thing)`, use `.specific-container:has(.some-deep-thing)`. The engine limits its search.

The no-nesting rule I mentioned earlier. You cannot write `:has(:has(...))`. When I hit this, I either restructure the selector to use a different combinator, or I accept that this specific case needs JavaScript. It is rare.

One more. `:has()` inside `@supports` blocks is confusing because `@supports selector(:has(*))` is the right way to feature-detect, but most codebases I have seen just assume support now. In 2026 that assumption is safe unless you are building for a specific enterprise with locked browsers.

When I still reach for JavaScript

Anything with timing. Debouncing, throttling, scroll-based animations. CSS does not do time-based state well, and scroll-linked animations are a separate API (`animation-timeline`) that I am still waiting on full support for.

Anything that needs to persist. Local storage, session flags, remembered filter state across page loads. CSS is stateless by design.

Complex forms with multi-step validation that depends on server responses. `:has(input:invalid)` is great for synchronous browser validation. It does not help you when the server rejects an email as already taken.

And anything involving data fetching, obviously. I am not suggesting CSS replaces your API layer. I am suggesting it replaces the boring 30-line listeners you write to paint a parent when a child changes.

Bottom Line

`:has()` has been fully supported for over two years and I still see codebases that do not use it once. Six patterns cover most of what you would reach for JavaScript to do: parent-has-child styling, sibling sensing, empty state detection, container awareness, quantity queries, and scroll lock. Copy the snippets, scope them tight, and delete the JavaScript they replace. Your bundle gets smaller and the behavior gets faster because it runs in the style engine instead of a microtask.

Stay in the loop
New tools, drops, and AI experiments. No spam. Unsubscribe anytime.
Back to all articles