Native CSS Nesting and @scope: Component Styles Without a Preprocessor
Native CSS nesting makes writing shorter but isolates nothing; @scope makes a rule apply from a scope root down to a limit and keeps user content out with a donut scope. We rewrite the Bookstore card with nesting and prove with a computed-style hash that nothing changed, and measure how & differs from Sass, the specificity coming from :is(), proximity, and a comparison with Angular, ASP.NET Core CSS isolation and Shadow DOM.
Version note: This post follows the CSS Nesting Module and CSS Cascading Level 6 (@scope) specifications and the state of Baseline in November 2026; the examples were run in Chrome 158 and WebKit 27.2 via Playwright 1.65, with Angular 22.2 and TypeScript 6.0, and Firefox support is taken from web-features 3.46 data.
Guess First
A teammate who wrote Sass for years for Bookstore, the online bookstore used as the shared example across this site's series, heard that native CSS nesting had arrived in browsers and wrote the card styles out of habit like this:
.book-card {
&__title { color: rgb(185, 28, 28); }
}
In Sass this produces .book-card__title, a shortcut everyone who writes BEM uses every day. If we give the same line straight to the browser, without any build step, does <h3 class="book-card__title"> turn red? If not, what does the browser take this line to mean?
In this post we look at two tools for writing component styles without a preprocessor. Native nesting makes writing shorter but isolates nothing; @scope says from where to where a rule applies. We will use both on Bookstore's book card, then compare them with Angular's emulated encapsulation, ASP.NET Core's CSS isolation and Shadow DOM.
Native Nesting: Writing the Bookstore Card Nested
The book card's styles grew over six posts of this series: subgrid, the card without a cover, stock status, three kinds of container query. They were all separate rules starting with .book-card. I rewrote the file with nesting:
.book-list {
display: grid;
/* as many 14rem+ columns as fit; min() keeps one column from overflowing a tiny screen */
grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr));
gap: var(--space);
list-style: none;
margin: 0;
padding: 0;
/* z-index values inside the list never compete with the sticky header */
isolation: isolate;
}
/* every card borrows four rows from the list, so titles, authors and prices line up across a row */
.book-card {
display: grid;
grid-row: span 4;
grid-template-rows: subgrid;
grid-template-areas: "cover" "title" "author" "price";
row-gap: 0.25rem;
position: relative;
padding: var(--space);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background-color: var(--color-surface);
& > * {
margin: 0;
}
& > img {
grid-area: cover;
justify-self: center;
}
/* no & needed: a nested ".book-title" means ".book-card .book-title" */
.book-title {
grid-area: title;
}
.book-author {
grid-area: author;
}
/* the price row is shared, so the price sits at its bottom edge */
.price {
grid-area: price;
align-self: end;
}
/* a card without a cover image gets a tinted background instead */
&:not(:has(> img)) {
background-color: var(--color-tint);
}
/* zebra stripes count only the books that are in stock */
&:nth-child(even of .in-stock) {
border-color: light-dark(#93c5fd, #1e40af);
}
&[data-stock="0"] .price {
text-decoration: line-through;
color: var(--color-muted);
}
/* the editor's note runs across the whole card, under everything else */
.staff-note {
grid-column: 1 / -1;
}
&[data-format~="ebook"] .book-title::after {
content: " · e-book";
font-size: 0.75em;
color: var(--color-muted);
}
/* wide slot: cover on the left, text on the right */
@container book-card (width >= 32rem) {
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto 1fr;
grid-template-areas:
"cover title"
"cover author"
"cover price";
column-gap: var(--space);
/* 1cqi = 1% of the slot's inline size */
.book-title {
font-size: clamp(1.25rem, 1rem + 1.5cqi, 2rem);
}
}
/* name-only query: any card in the sidebar gets a smaller cover */
@container sidebar {
& > img {
inline-size: 4rem;
block-size: auto;
}
}
/* style query: the slot says it is dark, the card follows */
@container style(--surface: dark) {
border-color: #4b5563;
background-color: #111827;
color: #f9fafb;
.book-author {
color: #d1d5db;
}
}
}
.badge {
position: absolute;
inset-block-start: 0.5rem;
inset-inline-end: 0.5rem;
z-index: 1;
padding-inline: 0.5rem;
border-radius: var(--radius);
background-color: var(--color-accent);
color: #ffffff;
font-size: 0.75rem;
/* black or white, whichever contrasts more with the accent; #ffffff above stays for older browsers */
@supports (color: contrast-color(red)) {
color: contrast-color(var(--color-accent));
}
}
/* inside a card, links look like plain text (the title is a link), but only down to the editor's note:
links in .user-content come from the CMS and keep their underline */
@scope (.book-card) to (.user-content) {
a {
color: inherit;
text-decoration: none;
}
}
A nested rule picks up its parent's selector on its own. & stands for the parent: &:not(:has(> img)) means .book-card:not(:has(> img)). Since late 2023 browsers also support the relaxed syntax: if the inner rule starts with a class or an element name, you do not need to write &; .book-title { … } means .book-card .book-title. Container queries and @supports can go inside the rule as well; their declarations apply directly to the parent. The card's whole behaviour now lives in one block, and the next developer does not have to look for the answer to "what does the card look like in a narrow slot?" in another file.
Rewriting a file from scratch calls for proof that nothing changed. For that I wrote a small script that collects every computed property of every element in the catalog list and the left column and hashes them. I copied the same script to the tag of the previous post (11) and ran it there:
chromium 1280px light 26 elements, styles 50ea69a2d320dc35
chromium 360px dark 26 elements, styles 8c2e7baa93a5834a
webkit 1280px light 26 elements, styles cceaae2760f0c139
webkit 360px dark 26 elements, styles 2929edf0bb07047d
With the nested file:
node scripts/12-fingerprint.mjs
chromium 1280px light 26 elements, styles 50ea69a2d320dc35
chromium 360px dark 26 elements, styles 8c2e7baa93a5834a
webkit 1280px light 26 elements, styles cceaae2760f0c139
webkit 360px dark 26 elements, styles 2929edf0bb07047d
In two engines, at two widths, in the light and the dark theme, the hashes are identical. For a refactoring like this, that is enough to say "the page did not change" without a visual comparison tool. I learned one thing while writing the script: Chromium does not list the custom properties in a computed style in a fixed order, and hashed unsorted, the result can differ from run to run; so I sort the properties before hashing.
& Is Not Sass's &
Native nesting looks like Sass but has two important differences. I measured both on the lab page:
/* 1) & is a selector, not text: this is __title.book-card, not .book-card__title */
.book-card {
&__title { color: rgb(185, 28, 28); }
}
/* 2) a parent selector list is wrapped in :is(), which takes its most specific member */
#featured, .book-card {
& .price { font-weight: 700; }
}
.book-card .price { font-weight: 400; }
/* 3) every level of nesting adds the parent's specificity */
.catalog {
.book-list {
.book-card {
.book-title { color: rgb(29, 78, 216); }
}
}
}
.book-title.highlight { color: rgb(22, 163, 74); }
node scripts/12-nesting.mjs
node scripts/12-scope.mjs
--- .book-card__title
computed color: rgb(0, 0, 0)
--- .book-card .price
computed font-weight: 700
✓ (unlayered) & .price (1,1,0) 700 12-nesting.html
✗ (unlayered) .book-card .price (0,2,0) 400 12-nesting.html
--- .highlight
computed color: rgb(29, 78, 216)
✓ (unlayered) .book-title (0,4,0) rgb(29, 78, 216) 12-nesting.html
✗ (unlayered) .book-title.highlight (0,2,0) rgb(22, 163, 74) 12-nesting.html
--- webkit: .book-card__title rgb(0, 0, 0), .price 700, .highlight rgb(29, 78, 216)
The first block answers the guess: not a single rule matched .book-card__title, and the title stayed black, inherited from body. In Sass, & behaves like a piece of text and joins up with the __title after it. In native CSS, & is a selector; &__title is read as an element named __title combined with the .book-card class. Since there is no such element, the rule selects nothing, and there is no error either. You cannot shorten BEM names with native nesting; you have to write the full class name.
The second block is sneakier. When you write #featured, .book-card { & .price { … } }, the browser wraps the parent list in :is(): the rule becomes :is(#featured, .book-card) .price. And :is(), as we saw in the selectors post of this series, takes the highest specificity in its list. So even when the rule selects a price inside .book-card, its specificity is (1,1,0); that is the value DevTools shows too. The .book-card .price (0,2,0) written afterwards loses, and the price stays bold. Sass would have expanded the same code into two separate selectors, #featured .price, .book-card .price, and there would be no problem. If the parent list contains an id, splitting the nesting is the safest way.
Deep Nesting and Specificity Inflation
The third example in the lab is nested four levels deep: .catalog .book-list .book-card .book-title. Specificity (0,4,0). That is why .book-title.highlight (0,2,0), written later in the component's own file, loses, and the highlight colour never shows. Nesting makes it very easy to copy the structure of the HTML into the CSS; every level adds another class, and in the end overriding a rule takes an even longer selector. This is the nested version of the "specificity war" we covered in the first post of this series.
My rule: use nesting for a component's own parts and states (&:hover, &[data-stock="0"], container queries), not to mimic the structure of the page. In the book card the deepest level is two: .book-card and one of its parts. If a rule's specificity should not exceed one class, :where() or @scope, from the next section, is the better tool.
@scope: Scope Root, Limit and Donut Scope
Nesting makes writing easier but does not limit how far a rule reaches. Bookstore ran into a concrete case of this. In the book of the week card the title is now a link, and we want links inside the card to look like plain text. The same card also holds a short note that editors write in the CMS, and the links in that note should look like links:
<article class="book-card in-stock" data-format="hardcover ebook">
<img src="img/dune.svg" alt="Cover of Dune" width="120" height="180">
<h3 class="book-title"><a href="book.html">Dune</a></h3>
<span class="badge">Bestseller</span>
<p class="book-author">Frank Herbert</p>
<p class="price">349.00 TRY</p>
<div class="staff-note user-content">
<p>Our pick of the week. Read the <a href="guide.html">reading guide</a> first.</p>
</div>
</article>
If I write .book-card a { text-decoration: none; }, the rule also reaches the link inside the note. In the lab I put two approaches side by side:
/* 1) a plain descendant selector also reaches into the editor's note */
.leaky a { color: inherit; text-decoration: none; }
/* 2) donut scope: from the card down to, but not into, .user-content */
@scope (.scoped) to (.user-content) {
a { color: inherit; text-decoration: none; }
}
/* 3) proximity: the nearer scope root wins, whatever the source order */
@scope (.theme-dark) {
a { color: rgb(147, 197, 253); }
}
@scope (.theme-light) {
a { color: rgb(29, 78, 216); }
}
/* 4) the scope root adds no specificity: (0,1,0) here, against (0,2,0) below */
.scoped .price { color: rgb(185, 28, 28); }
@scope (.scoped) {
.price { color: rgb(22, 163, 74); }
}
<article class="leaky">
<h3><a href="book.html" id="leaky-title">Dune</a></h3>
<div class="user-content"><p>Read the <a href="guide.html" id="leaky-note">reading guide</a> first.</p></div>
</article>
<article class="scoped">
<h3><a href="book.html" id="scoped-title">Dune</a></h3>
<p class="price" id="scoped-price">349.00 TRY</p>
<div class="user-content"><p>Read the <a href="guide.html" id="scoped-note">reading guide</a> first.</p></div>
</article>
--- chromium
leaky-title rgb(0, 0, 0) underline no
leaky-note rgb(0, 0, 0) underline no
scoped-title rgb(0, 0, 0) underline no
scoped-note rgb(0, 0, 238) underline yes
light rgb(29, 78, 216) underline yes
dark-in-light rgb(147, 197, 253) underline yes
light-in-dark rgb(29, 78, 216) underline yes
scoped price rgb(185, 28, 28)
shadow p: margin-top 16px, weight 700, color rgb(75, 85, 99) | page p: weight 400
computed color: rgb(185, 28, 28)
✓ (unlayered) .scoped .price (0,2,0) rgb(185, 28, 28) 12-scope.html
✗ (unlayered) .price (0,1,0) rgb(22, 163, 74) 12-scope.html
chromium catalog: "Dune" rgb(249, 250, 251) underline no, "reading guide" rgb(158, 158, 255) underline yes
The first two lines show the problem: .leaky a removes the underline from both the title and the note, and the link in the note can no longer be told apart from the text. @scope (.scoped) to (.user-content), on the other hand, defines a donut: the rule applies from .scoped (the scope root) down to .user-content (the scope limit), excluding the limit itself and everything inside it. The title looks like plain text, and the link in the note stays underlined, in the browser's link colour (rgb(0, 0, 238)). In Bookstore I added the same rule to the card's file:
/* inside a card, links look like plain text (the title is a link), but only down to the editor's note:
links in .user-content come from the CMS and keep their underline */
@scope (.book-card) to (.user-content) {
a {
color: inherit;
text-decoration: none;
}
}
The last line of the output is the catalog page: the "Dune" link takes the card's text colour and is not underlined; "reading guide" is underlined. The link colour here is rgb(158, 158, 255), not the dark blue from the lab. The reason is the one line I added to the dark band:
/* a dark band: components inside read --surface with a style query */
.featured {
--surface: dark;
/* light-dark() tokens and the browser's own colours (links, controls) inside the band go dark too */
color-scheme: dark;
padding: var(--space);
border-radius: var(--radius);
background-color: #1f2937;
color: #f9fafb;
}
The band is now color-scheme: dark; that switches the light-dark() tokens from the previous post and the colours the browser draws itself (links, form controls) to the dark theme inside the band. Without this line, the link in the note stayed dark blue on a dark background. User content is HTML you did not write, so you cannot add classes or styles to it; the right tool is to keep component styles away from it and to set the right colours for its surroundings.
Proximity and Specificity
@scope adds a new step to the cascade: scope proximity. If two scoped rules match the same element and have equal specificity, the one whose scope root is closer to the element wins; source order only comes after that. The theme example from the lab:
<div class="theme-light"><a href="#" id="light">light</a>
<div class="theme-dark"><a href="#" id="dark-in-light">dark inside light</a></div>
</div>
<div class="theme-dark">
<div class="theme-light"><a href="#" id="light-in-dark">light inside dark</a></div>
</div>
In the output, dark-in-light is light blue and light-in-dark dark blue. In the source, the .theme-light rule comes later; without scope it would win in both nested cases, and links in a dark section placed inside the light theme would show in the wrong colour. Proximity makes the innermost theme win, which is exactly the behaviour you want for nested themes and components.
On the specificity side there is an important detail: the scope root adds no specificity to the selectors inside. The specificity of .price inside @scope (.scoped) { .price { … } } is (0,1,0); DevTools shows that too. That is why .scoped .price (0,2,0), written earlier, wins and the price stays red. Proximity is a step applied after specificity; it cannot beat an ordinary rule with higher specificity. That may look like a drawback, but it is really the opposite of the inflation in nesting: scoped rules are written with low specificity and are easy to override when needed.
Comparison with Angular, ASP.NET Core CSS Isolation and Shadow DOM
@scope is not the only way to tie styles to a component. To the card component in the Angular project we set up in the container queries post of this series, I added a title link and an area that shows the note from the CMS with [innerHTML]; the component's styles again contain a { color: inherit; text-decoration: none; }:
cd angular
npx ng build
cd ..
node scripts/12-angular.mjs
--- chromium
Emma underline no, attributes: _ngcontent-ng-c2542839406 href
Dune underline no, attributes: _ngcontent-ng-c2542839406 href
reading guide underline yes, attributes: href
a[_ngcontent-ng-c2542839406] { color: inherit; text-decoration: none; }
Angular's default emulated encapsulation adds an _ngcontent-… attribute to every element in the component's template at build time and narrows all of the component's selectors with that attribute: the a rule became a[_ngcontent-ng-c2542839406]. Elements added at run time with [innerHTML] do not get that attribute, so the link in the note stayed outside the rule and is underlined. In this example that is the result we want, but for the same reason the component's styles cannot reach into child components or innerHTML at all. If they need to, you have to fall back on ::ng-deep, which Angular has marked as deprecated.
ASP.NET Core's CSS isolation for Razor views applies the same idea on the server side: selectors in a file such as Index.cshtml.css are narrowed at build time with an attribute starting with b-, and the view's elements get that attribute; ::deep is used for child content. The details are in the Static Assets and UI Infrastructure post of the ASP.NET Core MVC series. Shadow DOM, in turn, leaves the separation to the browser itself. The <book-note> custom element in the lab:
<!-- 5) Shadow DOM: page rules stay out, inherited properties still come in -->
<section style="color: rgb(75, 85, 99)">
<book-note id="note"></book-note>
</section>
<p id="light-dom-p">A paragraph outside the shadow root.</p>
<script>
customElements.define('book-note', class extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML =
'<style>p { font-weight: 700; }</style><p>Staff pick of the week.</p>';
}
});
</script>
The "shadow p" line in the output: the page's p { margin: 0; } rule did not get into the shadow root (margin 16px, the browser default), and the p { font-weight: 700; } inside the shadow root did not get out (the paragraph on the page is 400). Inherited properties, however, cross the boundary: the colour came from the host's parent. Since custom properties are inherited too, a design system's tokens can reach into a shadow root.
| Approach | Who does the separation | Do page styles get in | When |
|---|---|---|---|
| Native nesting | Nobody; it only makes writing easier | Yes | Everywhere, for a component's own parts and states |
@scope | The browser, while matching selectors | Yes | A component boundary without a build step; keeping user content out; nested themes |
| Angular emulated | The Angular build, with an attribute | Yes | The default in Angular apps; component styles do not leak out |
| ASP.NET Core CSS isolation | The .NET build, with an attribute | Yes | Razor views and components; MVC and Razor Pages |
| Shadow DOM | The browser, with a separate element tree | No (only inherited properties and custom properties) | Widgets embedded into other pages, design system web components |
In Production
Native nesting, together with its relaxed syntax, became Baseline in December 2023 with Chrome 120, Firefox 117 and Safari 17.2, and has been widely available since June 2026. @scope became Baseline in March 2026 with Firefox 146 and Safari 26.4; it is newly available. A browser without support ignores the whole @scope block: in Bookstore that means the link in the card title shows up underlined. The page does not break; it just looks a little different. If you use it for something critical, such as keeping user content out, choose a default that stays on the safe side for older browsers.
In a nested file, always write declarations before nested rules. The behaviour of declarations that come after a nested rule changed in the specification in 2024; to avoid older and newer browsers giving different results, declarations should sit at the top and nested rules below them. Bookstore's card file follows that order.
Nesting is not the only reason to drop a preprocessor. Sass's mixins, loops, module system and functions are still missing from CSS or only just arriving (@function is not Baseline yet). The details of using Sass and native CSS together are in the Sass/SCSS series.
Common Pitfalls
First, writing &__title or &--active out of Sass habit. In native CSS these select nothing and raise no error.
Second, nesting under a parent list that contains an id or another high-specificity selector. :is() spreads the highest specificity over the whole list.
Third, copying the structure of the HTML into nested CSS. Every level adds specificity; nesting deeper than two levels is usually a warning sign.
Fourth, treating @scope as a specificity tool. The scope root adds no specificity; proximity only decides between rules of equal specificity.
A Small Experiment
★☆☆ Split the #featured, .book-card { & .price { … } } rule in the lab into two separate rules and check in the output of node scripts/12-nesting.mjs how the price's specificity changes.
★★☆ Rewrite form.css with nesting and extend 12-fingerprint.mjs to cover the form rows of checkout.html as well. Show that the hashes stay the same before and after you change the file.
★★★ Turn the summary paragraph in book.html into a .user-content block from the CMS (with a link and a list inside), and put the book detail page's component styles inside @scope (.book-page) to (.user-content). Verify with a Playwright script that the page's component rules do not reach the user content, while the inherited colour and font still do.
In the next post we move on to the moving parts of this series: the difference between transitions and animations, easing and linear(), a cart dialog that opens from display: none with @starting-style, and reducing animation with prefers-reduced-motion instead of switching it off.