Put Your CSS In Its Place with @scope
How @scope finally gives CSS the style boundaries it's always needed - and why that means BEM can retire.
As a parent of two, I’ve found writing CSS can be a bit like having toddlers - you tell them to only use the red pens and somehow, blue pen appears somewhere completely unexpected. The sofa. The wall. Something you haven’t seen in six months. CSS has the same problem. You tie up a new button style for a new part of a project and suddenly all the buttons across the site have that random border-radius or hover state.
The rules are loose and the damage spreads.
Enter @scope.
How it works
Let’s get to the code. How do we set this up so that completely-made-up scenario doesn’t even become a reality. Firstly, you use the @scope at-rule, following by your selector of choice - let’s drag the infamous card example out here.
@scope (.card) {}
Well…that’s kind of it, right? We’ve it set it up. Nothing more to do here, apart from adding in our styles within those lovely braces on the .card. Anything inside this ‘scoped’ card is exclusive just to that class name. This allows us to use more generic CSS selectors, ditching the specific class/IDs like .image in favor of just the img tag. Let’s do a comparison between a basic card layout using both the BEM naming convention and @scope.
Our example card markup
<div class="card">
<PlaceholderImage ratio="16 / 9" />
<div class="card__content">
<h3 class="card__heading">Card Title</h3>
<p class="card__text">
Amet aute minim eiusmod tempor do minim eu. Eu officia amet
consequat et esse pariatur Lorem proident aliqua reprehenderit
esse elit. Eiusmod laborum fugiat irure culpa adipisicing velit.
In consequat adipisicing ea consequat aute elit elit. Do proident
veniam commodo voluptate adipisicing ullamco et ut aliqua fugiat
id veniam do. Aliquip non incididunt ipsum occaecat cillum est
consequat consectetur cillum. Dolore in excepteur veniam adipisicing
pariatur Lorem.
</p>
<a class="card__link" href="#">Learn More</a>
</div>
</div>
This just gives us this beautifully unstyled masterpiece.
Card Title
Amet aute minim eiusmod tempor do minim eu. Eu officia amet consequat et esse pariatur Lorem proident aliqua reprehenderit esse elit. Eiusmod laborum fugiat irure culpa adipisicing velit. In consequat adipisicing ea consequat aute elit elit. Do proident veniam commodo voluptate adipisicing ullamco et ut aliqua fugiat id veniam do. Aliquip non incididunt ipsum occaecat cillum est consequat consectetur cillum. Dolore in excepteur veniam adipisicing pariatur Lorem.
Learn MoreBEM vs @scope - side by side
Same card. Same output. Very different markup.
<div class="card">
<picture class="card__image-container">
<img
class="card__image"
src="…"
alt="…"
/>
</picture>
<div class="card__content">
<h3 class="card__heading">Card Title</h3>
<p class="card__text">…</p>
<a class="card__link" href="#">Learn More</a>
</div>
</div> <div class="card">
<picture>
<img
src="…"
alt="…"
/>
</picture>
<div>
<h3>Card Title</h3>
<p>…</p>
<a href="#">Learn More</a>
</div>
</div> Card Title
Amet aute minim eiusmod tempor do minim eu. Eu officia amet consequat et esse pariatur Lorem proident.
Learn MoreCard Title
Amet aute minim eiusmod tempor do minim eu. Eu officia amet consequat et esse pariatur Lorem proident.
Learn More/* 6 selectors, block repeated throughout */
.card {
display: flex;
flex-direction: column;
row-gap: 16px;
border: 1px solid #000;
max-width: fit-content;
}
.card__image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.card__content {
padding-block-end: 16px;
padding-inline: 16px;
display: flex;
flex-direction: column;
row-gap: 4px;
}
.card__heading {
font-size: 24px;
letter-spacing: -0.04em;
line-height: 140%;
}
.card__text {
-webkit-line-clamp: 2;
overflow: hidden;
}
.card__link {
margin-inline-start: auto;
margin-block-start: 8px;
}/* 1 root, element selectors inside */
@scope (.card) {
--color-dark: grey;
display: flex;
flex-direction: column;
row-gap: 16px;
border: 1px solid var(--color-dark);
max-width: fit-content;
img {
aspect-ratio: 16 / 9;
object-fit: cover;
}
> div {
padding-block-end: 16px;
padding-inline: 16px;
display: flex;
flex-direction: column;
row-gap: 4px;
}
h3 {
font-size: 24px;
letter-spacing: -0.04em;
line-height: 140%;
}
p {
-webkit-line-clamp: 2;
overflow: hidden;
}
a {
margin-inline-start: auto;
margin-block-start: 8px;
}
}For browsers that don’t support @scope yet, wrap it in a feature query and keep your BEM styles as the fallback.
The one thing BEM couldn’t solve
BEM naming conventions are pretty good at stopping styles from leaking from one to another - for example .card__heading will never accidentally style a .nav__heading. But there’s always been a blind spot of nested instances of the same component. If a .card element can have another .card inside it, then every BEM rule on .card__heading will apply to both the inner and outer card. No mistake, no weird gotcha. That’s just how it works. There’s no way to specifically target just the ‘outer’ one without adding extra classes e.g. .card--outer, .card--parent, or using specificity hacks. With @scope, it has that same problem, but it also has a fix built in to rectify it.
It’s cards all the way down
So what’s the fix? We can use the to keyword to define where a scope stops:
@scope (.card) to (.card) {
h3 {
font-size: 24px;
}
}
The scope now ranges in the ring between the outer .card and any of the .card elements inside it. Styles reach the h3 within the containing outer .card, but stop before reaching any inner .card elements. The nested card gets its own clean scope when its own @scope rule kicks in.
<div class="card">
<h3>Outer card - gets font-size: 24px</h3>
<div class="card">
<h3>Inner card - unaffected</h3>
</div>
</div>
This is sometimes called the donut hole - a term coined by Nicole Sullivan in 2011 - meaning the scope has a gap punched through it at the lower boundary. Styles apply in the ring, not inside the hole. It’s an odd analogy, but it sticks.
When do you actually need to use this?
Not every component needs a lower boundary - most cards won’t nest, and hopefully we never enter a hellscape where developers are putting buttons inside buttons. But it’s worth reaching for whenever:
- A component can genuinely contain itself (comments with replies that are also comments, dropdown menu containing a nested submenu)
- Two different components share element selectors (h3, img, a) and one can appear inside the other
For the card we’ve built in this post, you probably don’t need it. But now you know it exists for when you do!
Theming without modifier classes
You might have noticed the @scope card example already does something quiet but useful with custom properties:
@scope (.card) {
--color-dark: grey;
border: 1px solid var(--color-dark, #000);
}
That --color-dark: grey value lives within the scope, and overrides whatever --color-dark is set to globally, but only within this card component. Everything else is given mercy and left alone completely.
The problem with global tokens
Design token systems typically live on :root. A colour palette, a spacing scale, a type ramp - all declared globally, all inherited everywhere. That’s fine, until you need a component to look different in a specific context. Let’s use an example of two cards in a promotional banner needing both light and dark backgrounds. When we’re using global tokens, we have to either write separate styles for each variant or use modifier classes/data attributes to switch between them. Both approaches mean the component’s styles are duplicated across variants.
/* The global token */
:root {
--card-bg: white;
--card-text: #222;
--card-border: #d4cfc0;
}
.card {
background: var(--card-bg);
color: var(--card-text);
border: 1px solid var(--card-border);
}
/* Modifier: override each token individually */
.card--promotional {
--card-bg: #2c5364;
--card-text: white;
--card-border: transparent;
}
Of course, this works, but the modifier class is doing two jobs: it’s both a style hook and a token override. As the number of variants grows, so does the amount of maintenance needed, the technical debt has amassed and lastly the code becomes harder to read.
The scoped token pattern
@scope lets you redefine tokens at the component boundary without a modifier class. The scope root becomes the theming surface:
/* Global token defaults */
:root {
--card-bg: white;
--card-text: #222;
--card-border: #d4cfc0;
}
/* Base component - uses whatever the tokens say */
@scope (.card) {
:scope {
background: var(--card-bg);
color: var(--card-text);
border: 1px solid var(--card-border);
}
h3 { color: var(--card-text); }
a { color: var(--card-text); }
}
/* Promotional context - redefine the tokens at the parent level */
.banner {
--card-bg: #2c5364;
--card-text: white;
--card-border: transparent;
}
<!-- Default card -->
<div class="card"> … </div>
<!-- Same card, different context - no modifier class needed -->
<div class="banner">
<div class="card"> … </div>
</div>
The card component itself hasn’t changed. The .banner parent reestablishes the tokens, and the card gets the new values through the cascade. One component has been defined, but it can work in any number of contexts.
The missing layer
A typical token/design system works mainly in one direction: it gets declared globally and is then taken on by components. Changing the global value will affect all components that use it - which is fantastic for rebranding a project effectively and with relative ease. But what if you need just one instance of a component to behave differently from all the others? A “Featured” card that sits at the top of a content feed for example, one that uses the same markup, same component, but has a dark background instead of light. What then?
Scoped tokens get involved in between the global and component layers. They let you redefine tokens at the context level, without the need to touch the component or the global defaults. This means that you can have a single instance of a component behave differently from all the other ones, without needing to modify the component itself or those site-wide defaults.
Further to this, we can use it alongside the lower boundary mentioned earlier, using the same functionality to stop styles leaking into other components.
/* Tokens redefined in the banner only reach as far as the first nested .card */
@scope (.banner) to (.card) {
--card-bg: #2c5364;
--card-text: white;
}
Migrate from BEM: A Practical Checklist
So, if the above sounds like a good fit for the next time you’re building out a component, or even better, get the budget to refactor an old one, the checklist below will help you get started. This checklist walks through how to go about replacing BEM class names with @scope on a component, using our card example above as a reference.
Before you start
Step 1 - Identify your scope root
Your BEM block becomes your @scope selector. The block class is the only class you need to keep on the HTML element.
/* BEM: block is the root */
.card { … }
.card__image { … }
.card__content { … }
/* @scope: same root, different shape */
@scope (.card) {
/* everything lives in here now */
}
Step 2 - Replace element classes with element selectors where possible
This is the cleanest win. BEM element classes exist to prevent style leakage - @scope handles that, so you can target img, h3, p, a directly inside the scope without them bleeding out.
/* BEM: explicit class on every element */
.card__image { aspect-ratio: 16/9; }
.card__heading { font-size: 24px; }
.card__text { -webkit-line-clamp: 2; }
.card__link { margin-inline-start: auto; }
/* @scope: element selectors are safe now */
@scope (.card) {
img { aspect-ratio: 16/9; }
h3 { font-size: 24px; }
p { -webkit-line-clamp: 2; }
a { margin-inline-start: auto; }
}
Step 3 - Handle modifier classes
BEM modifiers (—) don’t map directly to @scope - @scope solves leakage, not state. Modifiers still need a signal to attach to. Your options:
Option A: Keep a simplified modifier class (recommended)
Drop the block prefix but keep the modifier concept as a class on the root element.
/* BEM modifier */
.card--featured { border-color: gold; }
/* @scope equivalent */
@scope (.card) {
:scope.featured { border-color: gold; }
}
Option B: Use data attributes
Cleaner semantic separation between styling hooks and JS hooks.
/* @scope equivalent */
@scope (.card) {
:scope[data-variant="featured"] { border-color: gold; }
}
Option C: Use :has() for state-driven modifiers
Where the modifier is driven by content or state rather than an explicit class.
/* @scope equivalent */
@scope (.card) {
:scope:has(img) { /* card has an image - adjust layout */ }
}
Step 4 - Migrate scoped variables
If you’ve read the design tokens section above, you already know the full picture here. The short version for the migration:
Step 5 - Add a lower boundary if components nest
You’ve seen this in the donut hole section above - just the decision checklist here:
Step 6 - Clean up the HTML
Once the scoped CSS is confirmed working, the markup gets leaner.
Before:
<div class="card">
<picture class="card__image-container">
<img class="card__image" />
</picture>
<div class="card__content">
<h3 class="card__heading">Card Title</h3>
<p class="card__text">…</p>
<a class="card__link" href="#">Learn More</a>
</div>
</div>
After:
<div class="card">
<picture>
<img />
</picture>
<div>
<h3>Card Title</h3>
<p>…</p>
<a href="#">Learn More</a>
</div>
</div>
Step 7 - Remove the BEM styles
Only once the scoped version is confirmed working in all target browsers.
What you’re not migrating
A few BEM patterns that @scope doesn’t replace - keep these as-is:
- Global utility classes. .visually-hidden, .sr-only, .text-center - these aren’t component styles and shouldn’t be scoped. Leave them alone.
- Layout-level classes. .grid, .container, .stack - if these live above the component level, they belong outside any scope.
- Multi-component relationships. If you have BEM classes that describe a relationship between two separate components (.card—in-sidebar, .nav—has-dropdown), @scope doesn’t naturally express those. Keep them as classes or replace with container queries on the parent.
Quick reference
| BEM pattern | @scope equivalent |
|---|---|
.block {} | @scope (.block) { :scope {} } |
.block__element {} | @scope (.block) { element {} } |
.block--modifier {} | @scope (.block) { :scope.modifier {} } |
.block__element--modifier {} | @scope (.block) { element.modifier {} } |
| Nested component leak | @scope (.outer) to (.inner) {} |