09/26/2026
Native CSS vs Sass: nesting, :has(), @container and @scope
Most codebases no longer need Sass. Native CSS nesting, :has(), container queries and @scope now work in every major browser, and together they cover what a preprocessor was mostly used for: nesting, parent-aware styling, component-level breakpoints and scoped styles. Keep Sass if you rely on loops, mixins with logic, or class names built with &__element, which native nesting cannot do.
Why you may not need the build step any more
For a decade the case for Sass or Less came down to a short list: nesting, variables, and a way to keep one component's styles from leaking into another's. Custom properties took variables years ago. As of 2026 the rest of that list is native CSS that every current browser understands, and four features cover most of what a preprocessor was actually doing in a typical codebase:
| Feature | What it replaces | Baseline status (MDN) |
|---|---|---|
Nesting (&) |
Sass nesting | Widely available (Baseline since December 2023) |
:has() |
JavaScript that toggles a parent class | Widely available (Baseline since December 2023) |
| Container queries | Breakpoint mixins tied to the viewport | Widely available (Baseline since February 2023) |
@scope |
BEM prefixes, CSS Modules, deep selectors | Newly available, since March 2026 |
"Newly available" is MDN's term for a feature that works in the current version of every major engine but may be missing on devices that have not updated. Check your own analytics before depending on @scope without a fallback.
Nesting: mostly what you expect, with two differences
MDN's guide to using CSS nesting covers the syntax. The short version: write child rules inside the parent, and use & where you need the parent itself.
.card {
padding: 1rem;
h2 { margin: 0; } /* .card h2 */
&:hover { border-color: #666; } /* .card:hover */
.theme-dark & { color: #eee; } /* .theme-dark .card */
}
Early implementations required & before a nested element selector like h2. Current browsers accept the relaxed form above.
The trap: no string concatenation
The Sass habit that does not carry over is building class names from the parent:
.card {
&__title { font-weight: 600; } /* Sass: .card__title. Native CSS: not that. */
}
MDN is explicit that concatenation is not possible in native nesting, because a selector with no combinator is parsed as a type selector. If your codebase is built on BEM, this is the single biggest migration cost: every &__element and &--modifier has to be written out in full, or the naming convention retired in favor of @scope below.
The other trap: specificity
The & behaves like :is() wrapped around the parent selector list, so it takes the specificity of the most specific selector in that list. With a parent like #main, .card, a nested rule inherits the ID's weight for both branches. Sass would have expanded the two branches separately. Keep parent lists in nested blocks to one kind of selector and this never comes up.
:has(): styling a parent by what it contains
The :has() pseudo-class matches an element when any of the selectors inside it match relative to that element. It is the parent selector CSS lacked for most of its history, and it removes a whole category of JavaScript that existed only to add a class to a wrapper:
/* A form row with an invalid field gets a red edge */
.field:has(input:user-invalid) { border-left: 3px solid #b3261e; }
/* A card that contains an image gets a two-column layout */
.card:has(> img) { grid-template-columns: 200px 1fr; }
/* A heading immediately followed by a subtitle loses its margin */
h1:has(+ .subtitle) { margin-bottom: 0; }
Three limits worth knowing, all from MDN: :has() cannot be nested inside another :has(); pseudo-elements are not valid inside it or as its anchor; and its specificity is that of the most specific selector in its argument. Separately, MDN advises against anchoring it on broad elements such as body, :root or *: a rule like body:has(...) restyles the whole page and is expensive to re-check as the page changes.
The same shift happened earlier with layout: matching card heights across a row once took a jQuery routine and is now a grid property.
Container queries: breakpoints that belong to the component
A media query asks how wide the viewport is. A component in a sidebar does not care. Container queries ask how wide the component's own container is:
.slot { container-type: inline-size; }
.card { display: grid; gap: .75rem; }
@container (width > 480px) {
.card { grid-template-columns: 200px 1fr; }
}
The same card now lays itself out in one column in a narrow sidebar and two in a wide main column, with no modifier class and no breakpoint mixin.
The trap: a container cannot query itself
The query applies to descendants of the element that declares container-type. Put container-type on .card and a query that styles .card will not match; it looks for an ancestor container instead. Declare the container on a wrapper, as .slot does above. Also prefer inline-size over size: size contains both axes, so an element whose height came from its content collapses unless you give it an explicit height.
Container units come with it: cqi is 1% of the container's inline size, which makes fluid type that scales with the component rather than the window, such as font-size: clamp(1rem, 0.8rem + 2cqi, 1.5rem).
@scope: component boundaries without naming conventions
@scope limits a block of rules to a subtree, and optionally stops at a lower boundary. That lower boundary, the "donut", is the part nothing in Sass could do:
@scope (.card) to (.note) {
a { color: #b3261e; font-weight: 600; }
}
Links inside .card are styled; links inside a .note within the card are not. The upper bound is inclusive and the lower bound exclusive by default.
@scope also adds a cascade rule, scoping proximity. When two scoped rules conflict at equal specificity, the one whose root is fewer steps up the DOM wins, regardless of source order. A light-theme block nested inside a dark-theme block gets the light styles, which is what you meant and is not what two plain class selectors would give you.
Support: caniuse lists @scope in Chrome and Edge 118, Safari 17.4 and Firefox 146, at roughly 92% of global usage, and MDN marks it Baseline newly available since March 2026. For the remainder, the rules inside simply do not apply, so write them as enhancements over a readable default rather than as the only styling a component has.
A demo you can paste into a file
Save this as an .html file and open it. Drag the dashed box's corner to resize it: the card switches layout at 480px of its own width, the checkbox turns the card green through :has(), and only the first link is red.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Native CSS demo</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 2rem; }
.slot { container-type: inline-size; resize: horizontal; overflow: auto;
border: 1px dashed #999; padding: .5rem; margin-block: 1rem; }
.card {
display: grid; gap: .75rem; padding: 1rem;
border: 2px solid #ccc; border-radius: .5rem;
.thumb { width: 100%; aspect-ratio: 16 / 9; background: #ddd; }
h2 { margin: 0; font-size: 1.1rem; }
&:hover { border-color: #666; }
&:has(input:checked) { border-color: #1a7f37; background: #eefbf1; }
}
@container (width > 480px) {
.card { grid-template-columns: 200px 1fr; }
}
@scope (.card) to (.note) {
a { color: #b3261e; font-weight: 600; }
}
</style>
</head>
<body>
<p>Drag the dashed box's corner to resize it.</p>
<div class="slot">
<article class="card">
<div class="thumb"></div>
<div>
<h2>Resizable card</h2>
<p>A <a href="#">scoped link</a> in the card body.</p>
<p class="note">An <a href="#">unscoped link</a> inside the note.</p>
<label><input type="checkbox"> Mark as done</label>
</div>
</article>
</div>
</body>
</html>
Keep the preprocessor, or drop it?
- Your styles mostly nest, name variables and fence off components
- Native CSS covers it; the build step can go
- You rely on loops, mixins with logic or classes generated from a map
- Keep the preprocessor; native CSS has no equivalent yet
- Your class names are built with &__element and &--modifier
- Write them out in full, or retire BEM in favor of @scope
- You bundle many partials into one file
- Keep a build step for that, preprocessor or bundler
- Some visitors run older browsers
- Write @scope rules as enhancements over a readable default
If you keep Sass, let it emit native nesting instead of flattening it.
Questions this raises
Can native CSS nesting replace Sass nesting?
For nesting rules inside a parent, yes: child selectors, &:hover and ".theme-dark &" all work natively. What does not carry over is building class names by concatenation, such as &__title, which native CSS reads as a type selector rather than .card__title.
Which browsers support CSS nesting?
All current major browsers. MDN marks nesting Baseline since December 2023. Chrome 112 to 119 and Safari 16.5 to 17.1 required & before a nested element selector; Chrome 120, Safari 17.2 and Firefox 117 and later accept the relaxed form.
What is the difference between a container query and a media query?
A media query tests the viewport. A container query tests the size of an ancestor element declared with container-type, so the same component can lay itself out differently in a narrow sidebar and a wide main column.
Is @scope safe to use in production?
It works in the current version of every major engine, but it is only newly Baseline, so some visitors on devices that have not updated will not get it. Write scoped rules as enhancements over a readable default rather than as the only styling a component has.