Evergreen
Last updated:

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.

Descriptive alt text

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 More

BEM vs @scope - side by side

Same card. Same output. Very different markup.

BEM Markup 7 classes
HTML
<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>
Classes in markup
card card__image-container card__image card__content card__heading card__text card__link
Every element carries its own class. The block prefix is repeated 6 times.
@scope Markup 1 class
HTML
<div class="card">
  <picture>
    <img
      src=""
      alt=""
    />
  </picture>
  <div>
    <h3>Card Title</h3>
    <p></p>
    <a href="#">Learn More</a>
  </div>
</div>
Classes in markup
card card__image-container card__image card__content card__heading card__text card__link
The scope root is the only class you need. Element selectors handle the rest.
BEM Rendered output
Card image

Card Title

Amet aute minim eiusmod tempor do minim eu. Eu officia amet consequat et esse pariatur Lorem proident.

Learn More
@scope Rendered output
Card image

Card Title

Amet aute minim eiusmod tempor do minim eu. Eu officia amet consequat et esse pariatur Lorem proident.

Learn More
Identical output - the difference is entirely in the markup and stylesheet, not the result
BEM Flat selectors, block prefix repeated
/* 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;
}
@scope Nested, element selectors, one root
/* 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:

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) {}

Browser Support

Fully supported

Chrome 118
Edge 118
Firefox 146
Safari 17.4