DEV Community

Cover image for Do you still need SCSS? What Next.js and Angular 22.2 actually do with native CSS nesting
Damian Sire (Dev) for Angular

Posted on

Do you still need SCSS? What Next.js and Angular 22.2 actually do with native CSS nesting

Another comparison between Angular 22.2 and React/Next.js. The previous one covered @boundary, the new block for template errors: Template errors: React and Next.js vs Angular 22.2's @boundary.

Browsers now support nested CSS without a preprocessor:

.card {
  padding: 1rem;
  .title { font-weight: 600; }
  &:hover { outline: 1px solid; }
}
Enter fullscreen mode Exit fullscreen mode

Timeline: Firefox 117 in August 2023, Chrome 120 and Safari 17.2 in December 2023

Cross-browser support for the relaxed nesting syntax arrived in Firefox 117, Chrome 120 and Safari 17.2, according to MDN's compatibility data. Chrome 112 and Safari 16.5 already handled nested class selectors like .title; the later versions added nested rules that start with a tag, like h2 {}. Since June 2026, nesting is Baseline widely available. (Baseline "newly available" means it works in the latest Chrome, Edge, Firefox and Safari; "widely available" comes 30 months later.)

Nesting was probably one of the most common reasons to use Sass (the language behind your .scss files). Now it's native. The question is what happens when you combine it with each framework's style isolation.

TL;DR

  • Next.js: CSS Modules rename every local class, nested ones included, so nesting just works; the build also flattens it.
  • Angular: the CLI flattens nesting before encapsulating, so it works in component styles. Avoid nesting under ::ng-deep, and know the 22.2 :host trap if raw nesting reaches the compiler.
  • Sass: still worth it for mixins, loops, BEM names with &__, breakpoint variables and custom Angular Material themes. For the rest, native CSS is enough.

React and Next.js: you pick the isolation

React doesn't scope styles. className works like the HTML class attribute (react.dev), so a CSS class applies to the whole page. The Next.js styling docs list several options:

  • Tailwind CSS: recommended "for most styling needs".
  • CSS Modules: a .module.css file generates unique class names, so you can use .card in different files without collisions.
  • Global CSS: for what is truly global.
  • External stylesheets, Sass and CSS-in-JS: the other options.
import styles from './card.module.css';

<div className={styles.card}>
  <h2 className={styles.title}>…</h2>
</div>
Enter fullscreen mode Exit fullscreen mode

CSS Modules: two files with .card become x7f2aW_card and k93qdP_card after the build

Isolation comes from renaming the classes, and that covers nesting too: the build renames every local class it finds (anything not wrapped in :global()), nested or not. We put the .card snippet from the top in a card.module.css and ran next build (Next.js 16.4 with default settings; Turbopack, the default bundler, compiles CSS with Lightning CSS). This is the output, reformatted:

.mXu7RW_card { padding: 1rem; }
.mXu7RW_card .mXu7RW_title { font-weight: 600; }
.mXu7RW_card:hover { outline: 1px solid; }
Enter fullscreen mode Exit fullscreen mode

Both classes got the file's prefix, and the build also flattened the nesting. It flattened it even with a browserslist of modern browsers only: Turbopack lowers nesting regardless of your targets.

Angular: emulated encapsulation by default

Angular scopes each component's styles with no setup. Emulated encapsulation generates a unique HTML attribute per component (_ngcontent-X), adds it to the elements in its template, and inserts it into every selector in its styles. It adds a second one, _nghost-X, to the component's own host element (like <app-card>), and that's what :host selects.

Angular emulated encapsulation: the template and the selectors get the same _ngcontent attribute

Up to Angular 22.1, that rewrite didn't process style rules nested inside other style rules (rules inside at-rules such as @media were fine), as the description of PR #50693 says. With the .card snippet from the top, .title got no attribute, so it also styled the .title of any child component inside the card.

Before the fix: the nested .title got no attribute and also styled the .title of a child component inside the card

So the CLI took a shortcut: since @angular/build 19.2.8, released in April 2025, the default esbuild-based builders always flatten nesting in component styles before they reach the compiler (commit). .card { .title {} } reaches it as .card .title {}. It doesn't depend on which browsers you support: we tested a 22.2.1 project with a .browserslistrc of modern Chrome only, and it still flattened.

Default CLI build, AOT or JIT: @angular/build flattens nesting before the compiler adds attributes. Styles that skip it (non-literal JIT styles, webpack dev builds, some libraries) reach the compiler raw

Angular 22.2: the fix, more than three years later

Angular 22.2 scopes nested rules when they reach the compiler. If the parent selector uses ::ng-deep (Angular's deprecated escape hatch that turns encapsulation off for the rest of the selector), nested rules aren't encapsulated: :host ::ng-deep { .foo {} } behaves like :host ::ng-deep .foo {} (commit). In 22.2.0 and 22.2.1, a nested rule under ::ng-deep could still get the attribute when an earlier rule also used ::ng-deep; 22.2.2 fixed it (commit).

That exception only helps when raw nesting reaches the compiler. In a default CLI project, don't nest under ::ng-deep at all: the CLI's flattening turns :host ::ng-deep { .foo {} } into :is() .foo {}, which matches nothing (issue #58996, open since November 2024).

The first attempt dates from June 2023. A month later, Google's internal tests found a failure in a file with base64 images; the PR was marked as blocked in 2024 and closed in 2025. The second attempt was merged in August 2026 and shipped with Angular 22.2 in September: more than three years.

Timeline from PR #50693 in June 2023 to Angular 22.2.0 in September 2026 and 22.2.2 in October 2026

In a default CLI project you'll barely notice the fix, because your component styles already arrived flattened. It matters when raw nested CSS reaches the compiler: JIT components whose styles isn't a string literal (the CLI can't see those at build time), dev builds on the webpack builders (@angular-devkit/build-angular, which don't force flattening; production builds may still flatten it when minifying for your browser targets), or libraries built with ng-packagr that target only modern browsers, which ship nesting as is.

The :host and & trap

In those cases, 22.2 brings a new side effect. We ran the same CSS through the compiler from 22.1.8 and from 22.2.0:

/* what you write */
:host { &.active { color: red; } }

/* 22.1.8: works, the browser resolves [_nghost-X].active */
[_nghost-X] { &.active { color: red; } }

/* 22.2.0: no match, the host doesn't have _ngcontent-X */
[_nghost-X] { &.active[_ngcontent-X] { color: red; } }
Enter fullscreen mode Exit fullscreen mode

The host element has _nghost-X but not _ngcontent-X, so 22.2's rule stops matching; :host(.active) works

We confirmed it in Chromium with a JIT build of 22.2.1 whose styles was a constant, so the CLI couldn't flatten it: the rule stops applying. The same happens with :host { &:hover {} }. It's already reported as issue #71050, still unfixed in 22.2.2 (a fix, PR #71152, is open). Use the function form of :host instead: :host(.active) and :host(:hover), which is also the form that works with Shadow DOM. Your own component styles in a default CLI build (AOT or JIT with literal styles) aren't affected, because the CLI flattens them first.

This story shows how fragile it is to emulate encapsulation by rewriting selectors: every new CSS syntax has to be taught to the compiler, and every fix can break another combination.

Do you still need SCSS?

Sass is the most common preprocessor, so it's the one we compare. Less offers the same features discussed below (mixins with parameters, and loops through recursive mixins or each()), so the same reasoning applies. One practical difference: the Angular CLI compiles both (Less after you install the less package), while Next.js doesn't support Less out of the box (for Turbopack, its default bundler, it's "planned via plugins").

Native now: nesting, custom properties, math and color functions. Still a preprocessor: mixins, loops, BEM with &__, variables in media queries

What Sass used to give you that is now native:

  • Nesting: native, with two differences. & doesn't concatenate, so there's no BEM-style &__title, and its specificity is computed like :is() (MDN).
  • Variables: custom properties (--brand-color), which also change at runtime. But you can't use them in a media query condition: @media (min-width: var(--bp)) doesn't work, while a Sass variable does.
  • Math: calc(), min(), max(), clamp() and trigonometric functions like sin() are Baseline widely available. round(), mod() and rem() are newer: Baseline newly available since May 2024, widely available from November 2026.
  • Color functions: color-mix() (Baseline widely available) can lighten or darken by mixing with white or black. For exact adjustments like Sass's color.adjust() (the old, now deprecated lighten() and darken()), there's relative color syntax, which works in all current major browsers (Baseline since September 2024) but won't be widely available until March 2027.

What is still Sass:

Mixins: reusable blocks of styles with parameters.

@mixin truncate($lines) {
  display: -webkit-box;
  -webkit-line-clamp: $lines;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.description { @include truncate(2); }
Enter fullscreen mode Exit fullscreen mode

Loops: generating many rules from a list.

@each $name, $color in (ok: green, error: red, warning: orange) {
  .badge-#{$name} { background: $color; }
}
Enter fullscreen mode Exit fullscreen mode

A mixin clamps text to two lines; an @each loop generates three badge classes

Custom properties cover part of what a mixin did (a reusable value you can change per component), but CSS has no way to include a parameterized block inside another rule; the closest is a utility class that reads custom properties, which you have to add in the markup. There's also no way to generate rules in a loop. Native mixins (@mixin) aren't enabled by default in any browser (Chrome has them only as a prototype behind a flag), and @function, which returns a value rather than a block of declarations, ships only in Chrome and Edge 139+. And if you want a custom Angular Material theme through its supported theming API, you still need Sass (a file with the mat.theme mixin); without it, the supported alternative is one of the prebuilt CSS themes (guide).

Beyond emulated encapsulation

The platform has two native ways to keep styles in their place.

Shadow DOM, available today. Angular offers ViewEncapsulation.ShadowDom, which affects event propagation and content projection. Angular also registers each regular shadow root with its shared style host, so Angular-managed component styles are copied into it. ExperimentalIsolatedShadowDom skips that shared-style injection, although normal CSS inheritance, including custom properties, still crosses the shadow boundary (guide, implementation).

@scope, looking ahead. @scope limits styles to a DOM subtree. It works since Chrome 118, Firefox 146 and Safari 17.4 (Safari 26.0 to 26.3 didn't apply it to <input> and <textarea>; 26.4 fixed it), and it's Baseline newly available since March 2026. There's an open request to add an encapsulation mode based on it, and a team member replied that it's something they've been thinking about. It isn't on the roadmap, and some developers already switch Angular's encapsulation off and scope components with @scope by hand (example).

Four ways to keep styles in their place: CSS Modules and emulated encapsulation rewrite code; Shadow DOM and @scope are native

Our take

  • New project, React or Angular: native CSS is enough for most cases. Add a preprocessor if you need mixins, loops, BEM names built with &__, breakpoints as variables in media queries, or a custom Angular Material theme.
  • You write nesting, the browser gets flat CSS. With default settings, both Next.js and the Angular CLI (in component styles) flatten nesting at build time, and both keep the styles isolated.
  • In Angular, you can already use native nesting in component styles, because the default CLI builder flattens it before encapsulating. The exception is nesting under ::ng-deep (#58996). If raw nesting reaches your compiler (non-literal JIT styles, webpack dev builds, some libraries), move to 22.2.2 or later and write host states with :host(...). On the webpack builders, moving to the esbuild-based @angular/build:application builder also avoids the problem, because it flattens first.

The underlying difference remains. React lets you choose the isolation mechanism (and choosing well is on you). Angular solves it for you (and adapting it to new CSS is on the framework, sometimes years late).

React: you choose between Tailwind, CSS Modules, CSS-in-JS or global CSS. Angular: the framework provides emulated encapsulation

Top comments (0)