Designing a Style-Leak-Free Design System with CSS Cascade Layers (@layer) + @scope + Container Queries
Achieving Complete Component Style Isolation Without CSS-in-JS
If you're a frontend developer familiar with CSS specificity and BEM, you've surely experienced this situation before. A card component that was working perfectly suddenly starts looking wrong one day, and when you open DevTools, you find some mysterious style like .title { font-size: 2rem; } leaking into your component. I had a similar experience myself — a colleague who had been onboarded to the team for two weeks added a generic class while working on a landing page, and it simultaneously broke the typography of three design system components. It took half a day just to find the cause, and from that day on, BEM class names started getting longer and longer. The moment names like .ds-card__content--highlighted-title appeared, I realized something was fundamentally wrong.
If you're already using CSS Modules or Styled Components, you might think "aren't we fine?" To some extent, that's true. But when you become dependent on a build pipeline, face CSS-in-JS constraints in server components, or need to publish a library to npm, the story changes. The approach covered in this article works with native CSS alone — no JS runtime required — and can be used as-is on any framework. You don't have to abandon your existing tools right away; it's an approach that lets you introduce things gradually and resolve conflicts one by one.
Over 2024–2025, CSS itself has reached a level where it can solve this problem natively. The combination of @layer (CSS Cascade Layers) for declaring a global priority system, @scope for isolating component boundaries, and Container Queries for responding independently of placement context has now reached Baseline status where it can be used comfortably in production. Let's walk through how combining these three features creates a complete style isolation architecture, with real code examples.
Core Concepts
@layer — Declaring "Which Layer Does This Style Belong To"
@layer creates priority tiers that operate independently of CSS specificity. Once you declare the order at the top of your file, that order becomes the priority no matter where styles are written afterward.
/* Declared from lowest priority → highest priority */
@layer reset, tokens, base, components, utilities, overrides;With this declaration, the utilities layer always takes precedence over the components layer — even if the selector specificity is lower. Remember the old days when .card .title had higher specificity than .title, so you had to use bizarre selectors like .card .card__title.card__title or slap on !important to override styles? @layer eliminates that entirely.
Cascade Layers: A concept introduced in CSS Specification Level 5 that lets you directly define origin layers within author stylesheets. Priority is determined in the order of layer order → specificity → source order. And one important point: styles outside
@layer(unlayered) always take precedence over styles inside a layer. This is exactly why we wrap third-party library styles inside a layer.
For those unfamiliar with BEM (Block Element Modifier) or SMACSS (Scalable and Modular Architecture for CSS) — briefly, these are methodologies that try to prevent style conflicts by following consistent rules for naming CSS classes or organizing folder structures. Their limitation is that people have to follow the rules manually, and @layer shifts that responsibility to the browser engine to enforce.
@scope — Restricting "How Far This Style Reaches"
@scope sets boundaries so that styles only apply within a specific DOM subtree. It's not complete isolation like Shadow DOM, but it's a native method for preventing style leakage within the normal CSS flow.
/* Only touches .title inside .card — outer .title is unaffected */
@scope (.card) {
.title {
font-weight: bold;
color: var(--color-primary);
}
}
/* The `to` keyword can set a lower boundary as well */
@scope (.card) to (.card__footer) {
img { border-radius: 8px; }
}Looking at the to keyword — the moment the scope reaches .card__footer, style application stops. This prevents styles from unintentionally flowing into nested components. If you set the boundaries incorrectly, styles can leak into child components in the opposite direction, so it's good to get in the habit of opening the Cascade panel in DevTools to visually confirm the scope range.
There's one more concept worth knowing about @scope — "scope proximity." This is the principle that when components are nested, the style from the closer scope root takes precedence. When a .card is nested inside another .card and .title styles conflict, the inner .card's scope wins. The fact that DOM tree distance — not specificity — is the criterion might feel unfamiliar at first, but it actually feels intuitive once you start using it.
The distinction between
@layerand@scope: "If scope determines what is being styled, layers determine why (from which tier) it is being styled." — Miriam Suzanne (CSS Working Group)
Container Queries — "Responds Automatically Wherever This Component Is Placed"
Traditional media queries are viewport-based, so when you use the same component in both a sidebar and a main grid, both render with the same layout. Container Queries use the size of the parent container as the reference point, making components respond differently depending on the context they're placed in.
/* Set container-type on the parent — not on the child */
.product-card {
container-type: inline-size;
container-name: product-card;
}
/* Based on the .product-card container, not the viewport */
@container product-card (min-width: 300px) {
.product-card__layout {
display: grid;
grid-template-columns: 1fr 2fr;
}
}It might be confusing at first why container-type is set on the parent rather than the child. It's because for a component to ask "how much space do I have?", the parent providing that space needs to declare itself as a container. Since @container queries work based on the nearest ancestor container, specifying container-name when nesting gets deep helps you avoid the confusion of an unintended container being used as the reference.
Note that container-type also has size (tracks both horizontal and vertical dimensions) in addition to inline-size (tracks only horizontal dimension). Using size increases layout calculation costs and can cause unexpected layout issues, so in most cases inline-size is the safer choice.
Each concept on its own is only a partial solution, but combining all three changes the story. A structure takes shape where layers manage the global hierarchy, scope protects component boundaries, and Container Queries respond automatically to placement context. Let's look at how this actually works in practice.
Practical Application
Example 1: Establishing the Design System Layer Structure
The first thing you can do is declare the overall layer order at the top of your entry file. If you spread layer declarations across multiple files, it becomes really hard to track later what order they were merged in. This is a situation I encounter often in practice — keeping the order declaration exclusively in one entry-point file, and only filling in styles within those layers in all other files, greatly improves the debugging experience.
/* design-system.css — declared once at the system entry point */
@layer reset, tokens, base, components, utilities, overrides;
@layer reset {
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
@layer tokens {
:root {
--color-primary: #0070f3;
--color-text: #1a1a1a;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--radius-md: 8px;
}
}
@layer base {
body { font-family: system-ui, sans-serif; color: var(--color-text); }
h1, h2, h3 { line-height: 1.2; }
}
@layer components {
.button {
background: var(--btn-bg, var(--color-primary));
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
border: none;
cursor: pointer;
}
}
@layer utilities {
/* utilities always takes priority over components — no !important needed */
.mt-4 { margin-top: var(--spacing-md); }
/* clip is deprecated, use clip-path instead */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}| Layer | Role | Examples |
|---|---|---|
reset |
Reset browser default styles | box-sizing, margin: 0 |
tokens |
Design tokens (custom properties) | --color-primary, --spacing-md |
base |
Default styles for HTML elements | body, h1~h6, a |
components |
UI component styles | .button, .card, .modal |
utilities |
Single-purpose helper classes | .mt-4, .sr-only |
overrides |
Emergency overrides · third-party redefinitions | Correcting external library styles |
Think for a moment about the state of the CSS in a project you're currently running. If files have been piling up without layer separation, this single declaration can resolve far more conflicts than you might expect.
Example 2: Complete Card Component Isolation with @scope + @layer
Nesting @scope inside @layer components gives you two things simultaneously: global priority management within the layer hierarchy, and style isolation within component boundaries.
@layer components {
@scope (.card) to (.card__actions) {
/* :scope refers to the .card root element itself */
:scope {
container-type: inline-size;
container-name: card;
border-radius: var(--radius-md);
overflow: hidden;
background: white;
}
/* This .title only works inside .card */
.title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
}
}
/* Layout changes when card container width increases */
@container card (min-width: 400px) {
@layer components {
@scope (.card) {
:scope {
display: grid;
grid-template-columns: 40% 1fr;
}
.title { font-size: 1.25rem; }
img { aspect-ratio: 4 / 3; }
}
}
}Honestly, when I first saw code with @container nested inside @layer nested inside @scope, I thought "is this even readable?" — but once you actually use it, the responsibility of each layer is so clear that tracking conflicts is actually easier. That said, this nesting structure requires actively using the Cascade panel in DevTools when debugging. If you can't see which rule came from which scope, it can be hard to make changes, so when first introducing this to a project, it's good to document DevTools usage together with your team.
Example 3: Public/Private Layer Pattern (When Publishing a Library)
This is a useful pattern when publishing a design system as an npm package. You can use layer names to distinguish between parts consumers can touch and internal implementation details they shouldn't.
There's one important point to watch out for: the layer declaration order. Since @layer gives higher priority to layers declared later, for ds.private — which contains fixed values for accessibility and functionality — to have higher priority than ds.public — the consumer customization area — ds.private must be declared later. Getting this order backwards creates the paradox where accessibility styles you designed to be "hard for consumers to override" are actually easily overridden.
/* Specify sublayer order — ds.private is declared after ds.public, so it has higher priority */
@layer ds.public, ds.private;
/* Public layer that consumers can freely customize */
@layer ds.public {
.button {
background: var(--btn-bg, var(--color-primary));
color: var(--btn-color, white);
padding: var(--btn-padding, 0.5rem 1rem);
border-radius: var(--btn-radius, var(--radius-md));
border: none;
}
}
/* Fixed values for accessibility and functionality — declared later to make it harder for consumers to override */
@layer ds.private {
.button {
outline-offset: 2px;
transition: background 0.2s, transform 0.1s;
}
.button:focus-visible {
outline: 2px solid currentColor;
}
}In consumer projects, you simply redefine CSS custom properties to adjust the theme. The method for giving types and default values to the custom properties stored in the tokens layer will be covered in the next installment when we discuss @property.
/* Consumer project — only redefine custom properties */
:root {
--btn-bg: #7c3aed; /* Replace with brand color */
--btn-radius: 9999px; /* Change to pill shape */
}Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| End of specificity wars | Priority can be declaratively controlled without !important |
| No build tools needed | Isolation works natively without CSS Modules or CSS-in-JS setup |
| Component autonomy | Works regardless of placement context — sidebar or grid |
| Improved team collaboration | Clear layer boundaries enable parallel development without conflicts |
| Portability | Works anywhere, independent of any framework |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
@scope browser support |
Firefox support added in 2025 (v146) (Baseline Newly Available, ~86% coverage) | For older browser requirements, use the postcss-cascade-layers plugin |
| Team learning curve | The entire team needs to understand the layer order > specificity priority model | Keep layer declarations in one file and document the design intent in the team wiki |
| Nesting complexity | Nesting of @container inside @layer inside @scope arises |
Explicitly specify container-name to clarify which container is the reference, and actively use the DevTools Cascade panel |
to boundary mistakes |
Incorrectly setting the lower boundary can cause style leakage into child components | Visually confirm scope range in DevTools |
| Layer order mistakes | Getting sublayer order like ds.private / ds.public wrong causes behavior opposite to intent |
Always use explicit order declaration in the form @layer A, B; |
Baseline Newly Available: Indicates a state where a specific CSS feature has been implemented across all major browsers — Chrome, Safari, Firefox, and Edge.
@scopeentered this state with the addition of Firefox 146 support in 2025.
The Most Common Mistakes in Practice
-
Spreading layer declarations across multiple files: Writing
@layer componentsin this file and that file makes it hard to track what order they were merged in. The order declaration (@layer reset, tokens, ...) should only exist in one entry-point file. I experienced this mistake myself — the layer application order differed between build environments, so things looked fine locally but broke in staging. -
Using
@scopealone without@layer: Styles outside@layer(unlayered) always win in cascade priority over styles inside a layer. When@scopeis used alone, the styles end up outside the layer, which can give them unintentionally higher priority than existing styles already managed with a layer structure. Using the form@layer components { @scope (.card) { ... } }— always wrapping it inside a layer — improves predictability. -
Deeply nesting without
container-name: If you only specifycontainer-type: inline-sizewithout giving it a name,@containerqueries can end up using a closer, different container as the reference instead of the intended ancestor. When containers are nested two or more levels deep, it's safer to explicitly specifycontainer-name.
Closing Thoughts
When I first introduced this architecture to the team, the question "why is this style winning here?" almost completely disappeared from code reviews. When you look at the layer declaration, the priority system is visible at a glance, and with scope boundaries, the fact that "this .title only works inside this component" becomes clear from the code itself. The combination of defining global tiers with @layer, isolating components with @scope, and making them respond to context with Container Queries enables complete design system isolation without CSS-in-JS. All three features are now at Baseline status, so for new projects you can adopt them immediately without any build tools.
Three steps you can take right now:
-
Add a layer declaration to the top of your existing CSS file (estimated time: 15 minutes): Start by adding
@layer reset, base, components, utilities;as a single line at the very top ofstyle.cssand wrapping existing styles inside the appropriate layers. Even without changing all the code immediately, just having the declaration starts the layer behavior. -
Apply
@scopeto one of your most frequently conflicting components (estimated time: 30 minutes): If your project has a place where generic classes like.titleor.descriptioncause conflicts across multiple components, try wrapping just that one component in@scope (.my-component) { ... }to directly experience the isolation effect. -
Replace one media query with a Container Query (estimated time: 1 hour): If you have a card component used simultaneously in a sidebar and main column, try switching the viewport-based
@mediatocontainer-type: inline-size+@containerand immediately see the difference in how it responds differently per placement context.
Next article: Using CSS
@propertyto give types and animations to custom properties stored in thetokenslayer — elevating CSS variables to the next level with type-safe design tokens and the Houdini API
References
- Organizing Design System Component Patterns With CSS Cascade Layers | CSS-Tricks
- Cascade Layers Guide | CSS-Tricks
- @scope | MDN Web Docs
- Public and private CSS cascade layers in a design system | Go Make Things
- CSS @scope and @layer: 3 Patterns That Replace Complex Methodologies | Medium
- Mastering CSS Cascade Layers for Scalable Design Systems | Design Systems Collective
- Modern CSS Trends 2025: Container Queries, Subgrid, Cascade Layers | Medium
- CSS: Nesting, Layers, and Container Queries | Builder.io
- Improving CSS Architecture with Cascade Layers, Container Queries, Scope | W3C TPAC 2021
- Scoped styles with @scope: A 2025-ready field guide | Medium
- Cascade Layers | Panda CSS Official Docs
- CSS Cascade Layers Vs. BEM Vs. Utility Classes | Smashing Magazine
- Cascade Layers | MDN Learn Web Development