We rebuilt the navigation on a client’s e-commerce theme last spring and deleted 214 lines of JavaScript in the process. Not because we found a cleverer script. Because the only thing that JavaScript did was add and remove classes so CSS could react to state that was already sitting right there in the DOM.
body:has(dialog[open]). .field:has(input:user-invalid). .nav:has(> li:nth-child(8)). Three selectors replaced an entire state-syncing layer, and the result had fewer race conditions because there was nothing left to get out of sync.
CSS nesting, the :has() selector and the rest of the modern CSS features that landed between 2022 and 2025 didn’t just add syntax sugar. They changed which problems belong to CSS and which belong to JavaScript. Here’s what we’ve learned shipping them on production sites, including the parts that bit us.
- Native nesting is not Sass nesting:
&behaves like:is(), which means.card, #hero { & .title {} }inherits the ID’s specificity, and BEM-style&--primaryconcatenation simply does not exist. - Declarations placed after a nested rule used to be hoisted or dropped. The CSSNestedDeclarations fix shipped across browsers through late 2024 and 2025, so if you support older Chromium builds, keep plain declarations above nested rules.
:has()is non-forgiving by design, so one unsupported selector inside it kills the whole rule. That’s deliberate: it makesCSS.supports('selector(:has(*))')a reliable feature test.- Scope
:has()to a container rather than writing it against*or unqualified elements. Invalidation cost scales with how many subjects the browser must re-check on every DOM mutation. @layer,@scopeand@containertogether do most of what BEM was invented to fake. Cascade layers solve override order, scope solves leakage, container queries solve component-level responsiveness.
CSS nesting looks like Sass and behaves differently
Everyone’s first reaction to native css nesting is to paste in a Sass block and watch it work. Then it doesn’t, and the failure is silent.
The first trap is specificity. The & in native nesting is defined as equivalent to :is() wrapping the parent selector list, and :is() takes the specificity of its most specific argument.
.card, #hero {
color: #222;
/ This resolves to :is(.card, #hero) .title /
/ Specificity is 1,0,1 because of #hero, not 0,1,1 /
& .title { color: crimson; }
}
In Sass that compiles to two separate selectors with independent specificity. In native CSS it’s one selector, and a single ID anywhere in the parent list poisons everything nested under it. We hit this on a legacy build where #main was still in the markup, and a nested utility class stopped overriding anything. Took twenty minutes to find. The rule we settled on: no IDs in selector lists that have children nested inside them.
The second trap is that there is no string concatenation. This is the one that ends the argument for BEM shops:
.btn {
/ Invalid. Native nesting has no interpolation. /
&--primary { background: blue; }
}
If your naming convention depends on building selectors from fragments, native nesting gives you nothing. Stay on Sass or switch conventions. We switched conventions. Attribute or data-driven variants (.btn[data-variant="primary"]) nest cleanly and read better in the inspector anyway.
The declaration order bug
This one cost us an afternoon. In the original nesting implementation, declarations that appeared after a nested rule were either invalid or silently reordered to the top of the block:
.btn {
background: blue;
&:hover { background: navy; }
/* Older engines hoisted this above the :hover rule.
The hover state stopped working and nothing warned you. */
background: rebeccapurple;
}
The CSSNestedDeclarations rule fixed this, landing in Chrome 130 and rolling out through the other engines over the following months. Behaviour is now what you’d expect: source order wins. But if your analytics still show meaningful traffic on Chromium 120 to 129 builds (embedded browsers and Android WebViews lag hard), write your plain declarations first. It costs nothing and removes a class of bug that produces no console output.

The :has() selector is a state engine, not a parent selector
The pitch everyone heard was “parent selector”. That’s the least useful framing. What :has() actually gives you is the ability to style an element based on a condition anywhere in its subtree or among its following siblings. The DOM itself becomes your state store.
Form validation is the clearest win. No JS, no dirty-checking, no class toggling:
.field {
--border: #d0d5dd;
border: 1px solid var(--border);
}
/* :user-invalid only fires after the user has interacted,
unlike :invalid which flags empty required fields on load */
.field:has(input:user-invalid) { --border: #d92d20; }
.field:has(input:user-valid) { --border: #12b76a; }
.field:has(input:focus-visible){ --border: #2970ff; }
/ Show the error text only when there is an error /
.field .error { display: none; }
.field:has(input:user-invalid) .error { display: block; }
Quantity queries are the second pattern we use constantly. Change a grid’s density based on how many children it actually has:
.gallery { --cols: 2; display: grid; grid-template-columns: repeat(var(--cols), 1fr); }
.gallery:has(> :nth-child(5)) { --cols: 3; } / 5 or more items /
.gallery:has(> :nth-child(9)) { --cols: 4; } / 9 or more items /
And the previous-sibling selector we never had. :has(+ .x) reads awkwardly the first time and then becomes second nature:
/ Dim the row above the one being hovered /
.row:has(+ .row:hover) { opacity: .6; }
/ Kill the bottom border on the last visible accordion item /
.accordion-item:not(:has(+ .accordion-item)) { border-bottom: 0; }
Feature detection actually works here
:has() is a non-forgiving selector list. Put one thing the browser doesn’t understand inside it and the entire rule is discarded. That was a deliberate spec change so that CSS.supports() stays truthful:
// Reliable because :has() rejects unknown arguments rather than ignoring them
if (!CSS.supports('selector(:has(*))')) {
document.documentElement.classList.add('no-has');
}
Practically, Chrome has had it since 105, Safari since 15.4 and Firefox since 121, so as of 2026 you’re arguing about a rounding error of traffic. We ship :has() unguarded on public marketing sites and only add fallbacks when a client’s own analytics show meaningful legacy browser usage, which for enterprise intranets it sometimes still does.
Where :has() gets expensive
The style invalidation story is genuinely good now. Chromium’s implementation narrows the set of elements it must re-check rather than invalidating the document, and in normal component-scale usage you will not measure it. That doesn’t mean you can be careless.
The pattern that actually hurts is a broad subject with a deep descendant condition on a mutating tree. Something like *:has(.is-active) or an unqualified div:has(input:checked) inside a virtualised table with thousands of rows. Every DOM insertion in that subtree makes the engine reconsider ancestors.
Three rules that have kept us out of trouble:
- Qualify the subject.
.data-table:has(tbody:empty), never:has(tbody:empty)on its own. - Prefer sibling conditions over deep descendant ones when both express the same thing.
:has(+ .x)checks a bounded set. - Profile with the real dataset. Chrome DevTools Performance panel, record an interaction, look at “Recalculate Style” duration and the “elements affected” count. If style recalc is climbing past a couple of milliseconds per interaction on a mid-range Android device, you have a selector to rewrite. That measurement takes ninety seconds and settles the argument.
@layer and @scope do what BEM was faking
Cascade layers were the quietest big change. You declare your priority order once, at the top of your entry stylesheet, and specificity fights inside a layer can never beat a later layer:
@layer reset, tokens, base, components, utilities;
@layer components {
.card { padding: 1.5rem; }
}
@layer utilities {
/ Single class wins over anything in components, no !important needed /
.p-0 { padding: 0; }
}
That is the entire reason utility classes historically needed !important. One line of @layer removes it. If you’re integrating an third-party stylesheet you can’t edit, @import url(vendor.css) layer(vendor) puts it in a box.
@scope handles the other half: leakage. It gives you a lower boundary, which nothing else in CSS ever has:
/ Style links inside prose, but stop at any embedded widget /
@scope (.prose) to (.widget, .card) {
a { text-decoration: underline; text-underline-offset: .15em; }
}
The “donut scope” is the part worth remembering. Everything between the outer selector and the inner one is styled; the inner boundary and its subtree are excluded. That is exactly the problem you hit when a rich-text field contains a shortcode or a block. If you maintain a library of reusable WordPress block patterns, scoping each pattern’s styles this way stops editor content from inheriting things it shouldn’t, and it survives clients pasting patterns inside other patterns.
The modern CSS features we now use on every build
Beyond nesting and :has(), this is the shortlist that has actually changed our default stylesheet:
light-dark()pluscolor-scheme. One property, no media query duplication, and it respects a user override set on a subtree. We cut our dark mode token file roughly in half converting to it.- Container queries.
container-type: inline-sizeon the component wrapper, then@container (min-width: 30rem). Components that work in a sidebar and a full-width hero without variant classes. Style queries (@container style(--variant: featured)) cover the rest. text-wrap: balanceon headings,prettyon body copy. Balance is capped at a handful of lines by design, so don’t put it on paragraphs. It kills the single-word last line on card titles, which is the typographic complaint clients raise most often.@starting-stylewithtransition-behavior: allow-discrete. This is what finally made popovers and dialogs animate in without JavaScript, because you can transitiondisplayandoverlaydiscretely.- Subgrid. Card grids where the title, body and footer align across every card regardless of content length. Previously impossible without fixed heights.
field-sizing: content. Auto-growing textareas with zero script. Chromium only for now, but it degrades to a normal textarea, so the downside is nothing.- Anchor positioning. Shipped in Chromium and Safari, still landing in Firefox at time of writing. We use it behind
@supports (anchor-name: --x)with a Popper-style fallback on projects that need parity, and unguarded on internal tools.
Canvas moved to Bootstrap 5 with these primitives layered on top rather than replaced, which is the pragmatic order: the grid and components stay predictable, and nesting plus custom properties handle the theming. If you want to see the pattern applied across a large component set rather than a demo page, the Canvas template build is a reasonable reference for how far you can push native CSS before reaching for tooling.
What still needs a preprocessor
Sass is not dead, it’s just narrower. We still reach for it when a project needs generated selectors (looping to produce a spacing scale), compile-time math that calc() can’t express, or a shared token file consumed by both CSS and a JS build.
Everything else has moved. Variables belong to custom properties, which are live and cascade-aware in a way Sass variables never were. Nesting is native. Colour manipulation is color-mix() and oklch(), which work at runtime and can respond to a theme change without a rebuild. Partials are @import with layers, or just an HTTP/2 connection and separate files.
The honest position: if you’re starting a new project in 2026 and reaching for Sass reflexively, check whether you’ll use anything beyond nesting and variables first. If not, you’re adding a build step and a source map to solve problems the browser already solved.
Frequently Asked Questions
Do I still need Sass if I use native CSS nesting?
Only if you need loops, compile-time functions, or selector concatenation like &--modifier. Native nesting covers the ergonomics of writing component styles, and custom properties beat Sass variables for anything theme-related because they’re live at runtime. Most of our new builds ship plain CSS with a minifier and nothing else.
Is :has() slow enough to matter on a real site?
Not at component scale. It becomes measurable when you write a broad subject like *:has(...) or apply a descendant condition to thousands of frequently mutating elements. Qualify the subject with a class, record an interaction in the Chrome DevTools Performance panel, and check the Recalculate Style entries. If it’s under a couple of milliseconds on a throttled CPU, ship it.
Why do my declarations after a nested rule get ignored?
You’re on a browser build from before the CSSNestedDeclarations fix, which landed in Chrome 130 and followed in the other engines. Those engines hoisted trailing declarations to the top of the block or dropped them entirely, with no console warning. Move plain declarations above any nested rules and the ambiguity disappears.
Can I nest one :has() inside another?
No. Nesting :has() inside :has() is invalid by spec, and so are pseudo-elements inside the argument. You can, however, combine :has() with :not(), :is() and sibling combinators freely, which covers almost every real requirement.
Should I use @scope instead of CSS Modules or scoped styles in my framework?
If you already have build-time scoping from a framework, @scope adds little except the lower boundary, which build-time tools cannot express. Where it earns its place is in CMS and template contexts: WordPress patterns, rich-text output, third-party embeds, anywhere content is nested by a person rather than a compiler. That donut behaviour is genuinely unique.
Where to start
Pick one page on your current project and audit the JavaScript that exists purely to toggle a class based on DOM state. Form validation styling, empty-state handling, scroll locks when a dialog opens, counting children to change a layout. Every one of those is a :has() rule now, and each deletion removes a piece of state that could desynchronise.
Then add @layer reset, base, components, utilities; to the top of your stylesheet and delete every !important that was only there to win an override fight. Those two changes take an afternoon and they will teach you more about what modern CSS has actually solved than another list of features will.
