External Publication
Visit Post

Put Your CSS In Its Place with @scope

Dom Jay February 15, 2025
Source
import PlaceholderImage from '../../components/PlaceholderImage.astro'; import InlineCallout from '../../components/InlineCallout.astro'; import ScopeDemo1 from '../../components/demos/ScopeDemo1.astro'; import ChecklistItem from '../../components/ChecklistItem.astro'; 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 at-rule, following by your selector of choice - let's drag the infamous card example out here. 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 . 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 in favor of just the tag. Let's do a comparison between a basic card layout using both the naming convention and . Our example card markup 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 More BEM vs @scope - side by side Same card. Same output. Very different markup. For browsers that don't support 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 will never accidentally style a . But there's always been a blind spot of nested instances of the same component. If a element can have another inside it, then every BEM rule on 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. , , or using specificity hacks. With , 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 keyword to define where a scope stops: The scope now ranges in the ring between the outer and any of the elements inside it. Styles reach the within the containing outer , but stop before reaching any inner elements. The nested card gets its own clean scope when its own @scope rule kicks in. 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 card example already does something quiet but useful with custom properties: That value lives within the scope, and overrides whatever 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 . 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. 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 lets you redefine tokens at the component boundary without a modifier class. The scope root becomes the theming surface: The card component itself hasn't changed. The 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. 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 on a component, using our card example above as a reference. Before you start Check your browser support requirements. is supported in Chrome 118+, Firefox 120+, and Safari 17.2+. If you're supporting anything older, wrap your scoped styles in a feature query and keep BEM as the fallback: - it adds two lines and costs nothing. Pick one component to migrate first. Don't rewrite everything. Choose a self-contained component - a card, a nav item, a badge - and use it as your proof of concept before touching shared or global styles. Have the original BEM styles open alongside. You're not deleting them yet. Migration goes: write the scoped version → confirm it works → remove the BEM version. 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. Identify the block class - this becomes your @scope (.block-name) selector Keep the block class on the HTML element (.card, .nav, .badge) - it's now the scope root, not just a selector Remove the block prefix from every element class in your markup - cardimage becomes image, or just gets removed in favour of an element selector (see step 2) 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. For each BEM element class, ask: is this targeting a semantically unique element within the component? (image → img, heading → h3) If yes - replace with the element selector inside the scope, remove the class from the HTML If no - the element isn't semantically unique (two different div wrappers, multiple span types) - keep a class, but drop the BEM prefix. .cardcontent becomes .content inside the scope; the scope handles the leakage 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. Option B: Use data attributes Cleaner semantic separation between styling hooks and JS hooks. Option C: Use :has() for state-driven modifiers Where the modifier is driven by content or state rather than an explicit class. List every --modifier class in the component Decide per modifier: simplified class, data attribute, or :has() condition Update the HTML and CSS together - don't leave orphaned modifier classes in the markup 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: Identify any custom properties currently set on the block class Move them inside @scope - they now only apply within the component Check that global tokens (:root variables) still resolve correctly from inside the scope - they will, but worth confirming if you're overriding the same property name If the component needs context-based theming (different values in a banner, a sidebar, a dark section) - set those token overrides on the parent element rather than using a modifier class on the component itself Step 5 - Add a lower boundary if components nest You've seen this in the donut hole section above - just the decision checklist here: Does this component ever nest inside itself, or inside a component with the same element selectors? If yes - add a lower boundary: If no - skip this step Step 6 - Clean up the HTML Once the scoped CSS is confirmed working, the markup gets leaner. Remove all blockelement classes now targeted by element selectors Remove all blockelement classes now targeted by simplified classes Keep: the block class (scope root) + any remaining modifier/variant classes The card from this post goes from 7 classes to 1 - just .card on the wrapper Before: After: Step 7 - Remove the BEM styles Only once the scoped version is confirmed working in all target browsers. Delete the original BEM block and element declarations Run a search for the old class names in your templates and markup - confirm none remain If you added an @supports fallback, confirm it's still in place and the BEM styles inside it are intentional 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 | | --- | --- | | | | | | | | | | | | | | Nested component leak | |

Discussion in the ATmosphere

Loading comments...