A Guide to Scoped CSS Using @scope, CSS Modules, and Shadow DOM
Compare @scope, CSS Modules, and Shadow DOM for scoping component styles, with complete code samples showing what each method protects against and costs.
If you style a component in 2026, the cascade can still reach in from anywhere: a global rule, a misplaced ID, a selector that out-specifies yours. Three mechanisms exist to stop that, and they work differently. This guide compares scoped CSS, CSS Modules, and Shadow DOM with the same component written three ways. It names what each protects against, what each costs, and gives you a decision rule. You type CSS every day. Here is what changed and what did not.
The Component We Are Styling
A card with a title, a body, and a button. No framework, no preprocessor. The cascade does its job. The problem is the same in any codebase: another component on the page also uses .card, .title, or .button, and the last rule in source order wins. You need containment. You have three ways to get it.
The card renders as a single DOM subtree: a <div class="card"> wrapping an <h3 class="title">, a <p class="body">, and a <button class="btn">. Each styling approach changes only how the CSS is written and how the browser or build tool applies it.
CSS @scope At-Rule: Native Containment with a Lower Boundary
@scope ships in all major engines. By 2026 it is Baseline. The syntax you will use is @scope (<scope-start>) to (<scope-end>) { <rule-list> }. The scoping root selector names the element that owns the subtree. The scoping limit selector names the lower boundary. Anything outside the root, and anything inside the limit, is untouched.
@scope (.card) to (.card__footer) {
.title {
font-size: 1.25rem;
font-weight: 600;
}
.body {
line-height: 1.5;
color: #333;
}
.btn {
background: #0066cc;
color: #fff;
border: 0;
padding: 0.5rem 1rem;
border-radius: 4px;
}
}
What the Browser Does
The browser parses the @scope at-rule, then matches each selector in the rule list only against elements that are descendants of the scoping root (.card) and not descendants of the scoping limit (.card__footer). The :scope pseudo-class inside the rule list refers to the scoping root itself, so you can write :scope > .title and skip the class name on the card entirely.
What It Protects Against, and What It Costs
@scope protects against specificity leaks. The rules inside the block do not add specificity to the inner selectors; .title stays at (0,1,0). A page-level rule with the same specificity and later source order can still override it, but the scoped rule wins if it comes later or if it uses :scope to raise the specificity of the root match. What it does not protect against: global name collisions. .title is still .title everywhere. @scope only limits where the rule matches. If another component uses .title, the two rules can both apply, and the cascade decides which wins.
@scope costs nothing at build time. No tooling, no class renaming. The cost is in the caveats. Common mistake one: expecting @scope to increase specificity of inner selectors. It does not. Common mistake two: using @scope without a limit selector and expecting it to prevent styles from leaking out. Without a limit, scoped styles still apply to all descendants of the root. The lower boundary is the only thing that stops the reach.
CSS Modules Hashed Class Names: Build-Time Scoping
CSS Modules are not a browser feature. They are a build tool transformation. Vite and Webpack both support them out of the box. You write normal CSS in a .module.css file, and the build tool rewrites each class selector into a hashed class name unique to that file. The JavaScript import gives you a map from the original name to the hashed one.
/* card.module.css */
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
}
.title {
font-size: 1.25rem;
font-weight: 600;
}
.body {
line-height: 1.5;
color: #333;
}
.btn {
background: #0066cc;
color: #fff;
border: 0;
padding: 0.5rem 1rem;
border-radius: 4px;
}
import styles from './card.module.css';
const card = document.createElement('div');
card.className = styles.card;
card.innerHTML = `
<h3 class="${styles.title}">Title</h3>
<p class="${styles.body}">Body text</p>
<button class="${styles.btn}">Action</button>
`;
How It Works and What It Stops
The build tool produces something like .card_abc123, .title_def456. The names are unique to the file, so the same class name in two different modules cannot collide. CSS Modules protect against global name collisions. The hashed class names make it impossible for another component’s .title to match yours, because the selector literally has a different name. What they do not protect against: specificity leaks. The specificity of .card_abc123 is still (0,1,0). A global rule with higher specificity still wins, and a later global rule with equal specificity still wins.
The Real Cost
CSS Modules cost a build tool. There is no browser support to check because the browser never sees the original classes. The cost is in the tooling chain: you must run a bundler, and the output CSS is larger because the hashed names are longer. The fallback if you cannot use a build tool is the older technique this replaces: double-class or BEM-style naming on all selectors inside a component. BEM works. It is manual. You are the build tool.
Shadow DOM Style Encapsulation: Real DOM Boundaries
Shadow DOM is a browser feature that creates a separate DOM subtree, a shadow root. Styles inside do not leak out. Styles outside do not leak in. Attach a shadow root to an element, append the component’s markup inside it, and write styles in a <style> element inside the shadow root. The outside world sees only the host element. The inside is a closed world.
<my-card></my-card>
<script>
class MyCard extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.title {
font-size: 1.25rem;
font-weight: 600;
}
.body {
line-height: 1.5;
color: #333;
}
.btn {
background: #0066cc;
color: #fff;
border: 0;
padding: 0.5rem 1rem;
border-radius: 4px;
}
</style>
<h3 class="title">Title</h3>
<p class="body">Body text</p>
<button class="btn">Action</button>
`;
}
}
customElements.define('my-card', MyCard);
</script>
What the Browser Does
The browser creates a shadow root. Every selector inside the <style> element matches only against the shadow root’s descendants. No outside selector can reach in, because the shadow root is a boundary in the DOM tree. The :host pseudo-class targets the host element (my-card) from inside, and ::slotted targets elements assigned to slots.
What It Protects Against, and the Cost
Shadow DOM protects against style bleed across DOM boundaries. It is the only one of the three that physically prevents outside rules from matching inside, and inside rules from matching outside. What it does not protect against: specificity leaks within the shadow tree. Inside the shadow root, the cascade still works normally. A later rule with equal specificity wins.
Shadow DOM costs inheritance severance. Inherited properties like color, font-family, and line-height do not cross the shadow boundary by default. The component must redeclare them or use custom properties. Custom properties do pierce the shadow boundary. That is how you theme a web component: set --card-title-color on the host, read it as var(--card-title-color) inside. The cost is also architectural. You commit to web components, which changes how you build the whole app, not just how you write a CSS file.
Component-Scoped Styles Comparison: Same Problem, Different Costs
You can use all three in the same project. Do not feel forced to pick one. The table below puts them on the same axes.
| Axes | @scope | CSS Modules | Shadow DOM |
|---|---|---|---|
| What it scopes | Selector matching to a DOM subtree | Class names via build-time hashing | The entire DOM subtree |
| Protects against | Specificity leaks | Global name collisions | Style bleed across DOM boundaries |
| Costs | Native, Baseline, no tooling | Build tool (Vite, Webpack) required | Severs inheritance; forces web components |
| Inner selector specificity | Unchanged | Unchanged | Unchanged inside the shadow tree |
| Lower boundary | Yes, via to () | No, the hashed name is the boundary | Yes, the shadow root is the boundary |
| Inheritance across boundary | Normal | Normal | Broken; custom properties pierce |
| Fallback technique | Double-class or BEM naming | None; the build tool is required | None; either you use it or you do not |
| Browser support | Baseline | Build output, no runtime support needed | All engines, long-standing |
The row that matters is the one you did not expect: all three leave specificity unchanged. Scoping is not a specificity tool. If your problem is that a global rule out-specifies your component, none of these fixes it. You fix that by lowering the global rule’s specificity, by using cascade layers to put the global rule in an earlier bucket, or by using a more specific selector inside your component. @scope keeps specificity the same; a (0,1,0) selector inside @scope is still (0,1,0). CSS Modules hashed names are still (0,1,0). Shadow DOM selectors are still (0,1,0) inside the shadow tree. The boundary changes where the cascade applies, not how strong the selector is.
When Each One Shuts Down: The Failure Modes
The honest version: each approach works until it does not. The failure mode tells you which one to reach for.
@scope
@scope fails when you forget the lower boundary. You write @scope (.card) { ... } and expect it to stop at the component edge. The rules still match every descendant. The fix: add to (.card__footer) or any element that marks the end of the component’s reach. It also fails in older browsers. Anything before the 2023 release of Safari, Chrome, and Firefox ignores the at-rule entirely, and your component renders unstyled. Check caniuse for current support. The fallback is double-class or BEM naming, which means you write both versions until your user base clears the cutoff.
CSS Modules
CSS Modules fail when the build tool is not configured. Drop a .module.css file into a plain HTML page and nothing happens. The browser has no idea what a module is. The failure is silent: the classes stay as written and the page renders with global styling. Check your bundler config. The real cost: you cannot use CSS Modules on a site that has no build step.
Shadow DOM
Shadow DOM fails when inheritance matters. Style a component, put it inside a page that sets color: #222 on the body, and the component’s text is black. The inherited color did not cross the boundary. Declare the inherited properties explicitly on the shadow root’s elements, or use custom properties for theming. The second failure is more subtle. If you use Shadow DOM, you commit to web components, and that changes how you handle events, slots, and lifecycle. If you only wanted scoped styles, Shadow DOM is a heavy hammer.
Which One Should You Use?
The decision rule is short. It depends on your build setup and your tolerance for architectural change.
If you already use a build tool, and your problem is class name collisions, use CSS Modules. It is the least invasive: you write normal CSS, the tool hashes the names, and nothing about the DOM changes. This is the right default for most application code in 2026.
If you have no build tool, or you need to style a component that ships as a standalone file, use @scope. It is native. It is Baseline. It handles the common case of a component whose styles should not leak into nested content. The missing lower boundary is a real trap. You learn it once and write it every time.
If you are building a reusable web component that other teams will drop into their pages, use Shadow DOM. It is the only one that gives you a real DOM boundary. The outside cannot break you and you cannot break the outside. Accept the inheritance severance and use custom properties as the theming API.
Do not use @scope and Shadow DOM together for the same component. @scope inside a shadow root is redundant. The shadow root already scopes, and @scope adds nothing but another layer of specificity confusion. Pick one boundary per component.
FAQ: Scoped CSS, @scope, Modules, and Shadow DOM
Does @scope increase specificity? No. The selectors inside the @scope block keep their original specificity. Scoping limits where a rule matches, not how strong it is.
Can I use CSS Modules without a build tool? No. CSS Modules is a build-time transformation; the browser never sees the original classes. Without a bundler, the .module.css file is a normal CSS file with no scoping.
Does Shadow DOM break inherited styles? Yes. Inherited properties like color, font-family, and line-height do not cross the shadow boundary by default. Custom properties do pierce the boundary, which is the intended theming mechanism.
What happens if a browser does not support @scope? The at-rule is ignored entirely, and the rules inside it are not applied. The fallback is double-class or BEM naming on all selectors inside the component, which you write in addition to the @scope version.
Can I use @scope and CSS Modules together? Yes, if your build tool supports it. @scope scopes the selector matching, and CSS Modules hashes the class names. They solve different problems. Combining them is not wrong, but it is unnecessary: CSS Modules already prevents name collisions, and @scope’s specificity protection is marginal.
One Honest Caveat
The last thing to carry away: none of these three fixes the cascade. The cascade is still there, and it still has the final say. @scope, CSS Modules, and Shadow DOM all change where the cascade can reach, but they do not change the rules of the cascade inside their boundaries. If a global rule with higher specificity exists, it wins. No scoping mechanism will stop it. The only tool that actually reorders the cascade is cascade layers, and that is a separate decision you make after you have chosen your scoping approach. The practical answer: use CSS Modules in a build, use @scope when you have no build, use Shadow DOM when you need a real DOM boundary, and never pretend that scoping is the same as cascade control.