Organising CSS in Component-Based Front-End Projects

Organise CSS by component using @scope, CSS Modules, or BEM. See the same button styled three ways and learn what each approach protects against.

The common assumption is that component-based CSS organisation means you must pick one tool, CSS Modules, BEM, or shadow DOM, and commit forever. That is wrong. The actual job is keeping styles scoped to the component that owns them, and the browser now ships a native way to do that with @scope, which works alongside the older approaches rather than replacing all of them. What matters is understanding what each mechanism protects against, because each one fails differently. A co-located CSS file using BEM naming conventions protects against leaking styles outward through the global namespace. CSS Modules protect against the same leak by hashing class names at build time, so two components can both use .button without colliding. Native @scope protects against leaking by limiting selector reach to a DOM subtree, with no build step at all. All three are attempts to solve the global-namespace problem, and the choice depends on your build pipeline, your browser support targets, and whether you can tolerate the failure modes each one carries.

Component-Based CSS Organisation: The Core Problem

The Tension Between Components And The Cascade

The phrase component-based CSS organisation names a specific tension: your component markup is a self-contained unit, but CSS has a global cascade. Every selector you write can match elements anywhere in the document unless you deliberately constrain it. The cascade, with its origin, layer, specificity, and source order, does not care about your component boundaries. So the organisation question is not about aesthetics. It is about preventing dead CSS from accumulating and preventing unwanted styles from bleeding between components.

Consider a button. In a co-located CSS file using BEM, you write something like this:

/* button.css */
.button { /* base styles */ }
.button--primary { /* primary variant */ }
.button__icon { /* icon inside button */ }

That works because BEM’s naming convention, block, element, modifier, makes every class name globally unique. The .button__icon class cannot accidentally match an icon in another component unless that other component also uses the same block name. What BEM protects against is leaking styles outward: your .button class will not style a .button in a third-party widget because the third-party widget uses a different block prefix.

What BEM Leaves Unprotected

BEM does not protect against receiving unwanted styles from elsewhere. If a global stylesheet has button { border: none; }, that rule still applies to your button because it targets the element type, not the class.

BEM also does nothing about dead CSS. When you delete a component, its CSS file may remain in the build, or a variant you stopped using stays in the file. Tree shaking does not work on plain CSS files; it works on JavaScript modules. So the co-located CSS file with BEM is the simplest approach, but it leaves you manually auditing which classes are still used. That is the trade-off: no build step, no hashing, but you own the maintenance.

CSS Component Architecture: When Hashed Class Names Help

How CSS Modules Work

The phrase CSS component architecture points to a stricter discipline: styles are not just co-located but compiled. CSS Modules are the canonical example. You write a file like this:

/* button.module.css */
.button {
  /* base styles */
}
.primary {
  /* variant styles */
}

Then in your JavaScript component, you import the module:

import styles from './button.module.css';

const button = `<button class="${styles.button} ${styles.primary}">Submit</button>`;

The build tool rewrites .button to something like .button_button__3KtP2. That hashed class name is unique to this module. Two components can both define a .primary class, and they will get different hashes, so they cannot collide. What CSS Modules protect against is leaking styles outward and receiving unwanted styles from elsewhere. The hash is the containment boundary.

Failure Modes You Must Know

CSS Modules have specific failure modes. The first: forgetting that composes creates a single class, not multiple classes. If you write .button { composes: base from './base.module.css'; }, the output class has both .button and the composed class’s styles applied, but the composed class name does not appear in the DOM. That changes specificity: the composed rules are merged into one selector, so they cannot be overridden by a separate class in the DOM. The second failure: importing a class name as a string and using it in a non-module context. If you write const cls = styles.button; and then use cls in a plain HTML string that is not processed by the build, you get the unhashed name, and the styles do not apply. The hash only works inside the module system.

CSS Modules also require a build step. They are not a browser feature. If you cannot use a bundler, or if you need to ship CSS directly from a static server, CSS Modules are not an option. That is where native CSS has caught up.

Scoped Component Styles CSS: The Native @scope Route

Writing A Scope Rule

The search for scoped component styles CSS has a modern answer: the @scope at-rule, specified in CSS Cascading and Inheritance Level 6. The syntax is straightforward:

@scope (.component) {
  .button {
    /* styles scoped to .component */
  }
}

This limits the selector’s reach to elements that are descendants of .component. You can also define a scope end:

@scope (.card) to (.card__footer) {
  .title {
    /* applies to .title inside .card, but not inside .card__footer */
  }
}

What @scope protects against is leaking styles outward, exactly like BEM and CSS Modules, but without a naming convention or a build step. The browser does the containment. It also changes how specificity works: within a scope, the selector’s specificity is computed relative to the scope root, so .button inside @scope (.component) has lower specificity than a .button outside the scope, but the scope boundary gives it priority for matching elements inside that subtree.

Critical Limitations

@scope has a critical limitation: it does not protect against receiving unwanted styles from elsewhere. A global rule like button { margin: 0; } still applies to a button inside @scope. @scope only constrains where your rules can match, not what other rules can match your elements. It is not style isolation in the shadow DOM sense. It is selector containment.

Another limitation: @scope does not create a new stacking context or a containing block. If you expected it to behave like a shadow root, it will not. Overflow, z-index, and position still behave according to the normal rules. The scope is purely about matching.

The Honest Cost Of Progressive Enhancement

The fallback for @scope is straightforward. Use a feature query:

@supports not (selector(::scope)) {
  /* unscoped fallback styles using BEM or CSS Modules naming */
}

This lets you ship the modern syntax to browsers that support it, and a BEM-based fallback to older engines. But note: the fallback styles must be written separately, because @scope does not degrade gracefully. If the browser does not understand @scope, it ignores the whole rule, and you have no styles. So the fallback is a duplicate set of rules, which doubles the maintenance burden. That is the honest cost of progressive enhancement here.

CSS Modules vs @scope: What Each One Actually Changes

Build Time Versus Runtime

The debate between CSS Modules vs @scope is not about which is better; it is about what each one changes in your pipeline and your runtime. CSS Modules change class names at build time. The output is plain CSS with hashed selectors. The browser never sees the original class names. @scope changes nothing about the class names; it changes the selector matching at runtime. The browser sees your original classes and applies the scope rules.

That difference has practical consequences. With CSS Modules, you get dead CSS elimination as a side effect of the module system: if a module is not imported, its CSS is not included. But you can still have dead CSS inside a module, if you define classes you never use in the module’s markup. With @scope, you have no automatic dead CSS removal. A @scope rule for a component that is removed from the DOM still ships in the stylesheet, because the CSS file is static. You still need manual auditing or a tool like PurgeCSS to strip unused rules.

Where Each One Works

Another consequence: CSS Modules are a JavaScript tooling feature. They require a bundler, and they tie your styling to the module graph. If you are working in a non-JavaScript context, a server-rendered page with no bundler, or a static site generator that emits plain CSS, CSS Modules are not available. @scope is native CSS, so it works in any context where you can write a <style> tag or a .css file.

There is also a specificity difference. With CSS Modules, the hashed class name has the same specificity as the original class name, because it is still a single class selector. With @scope, the scope root adds to the specificity calculation: @scope (.component) { .button } computes as if you wrote .component .button, which is two classes. That can matter when you are overriding styles from a framework or a design system. If a global rule uses a single class, it loses to your scoped rule because your scoped rule has higher specificity. If you want to keep specificity low, you can use @scope (.component) to (.end) { } and the specificity is still the sum of the scope root and the selector.

The real gap is that neither @scope nor CSS Modules gives you true encapsulation. Both are containment strategies for the global namespace. True encapsulation, where styles cannot leak in or out, requires shadow DOM, which is a different mechanism entirely.

Design System CSS Organisation: Layers, Tokens, and the Cascade

Priority Without Specificity Wars

The phrase design system CSS organisation is broader than component scoping. A design system has global concerns: tokens, base styles, utilities, and component concerns. The question is how to order them in the cascade so that components can override base styles without fighting specificity wars.

Cascade layers, specified in CSS Cascading and Inheritance Level 5, give you explicit priority buckets. You declare layers in order:

@layer base, components, utilities;

Later layers win over earlier layers, regardless of specificity. So a rule in @layer utilities beats a rule in @layer components even if the component rule has a higher specificity. That is the formal solution to what ITCSS and SMACSS tried to do with load order and naming conventions.

The syntax is simple:

@layer base {
  button { /* base styles */ }
}

@layer components {
  .button { /* component styles */ }
}

What @layer protects against is specificity hacks and !important battles. You no longer need to make a selector more specific to win; you move the rule to a later layer. That is a mental shift: instead of thinking about specificity, you think about priority.

Common Mistakes With Layers

The most common mistake with @layer is placing unlayered styles after the layer declarations. Unlayered styles always win over layered styles, regardless of order or specificity. So if you have @layer base { } and then later, outside any layer, you write button { color: red; }, that rule beats everything in base. The fix is to put all your styles in layers, or to declare your layer order at the top of the stylesheet so that unlayered styles do not exist.

Another mistake: nesting @layer inside @layer. The order of nested layers is independent of the parent layer order. If you have @layer a { @layer b { } @layer c { } }, the order of b and c is determined by their declaration order inside a, not by any global ordering. That is confusing, and it is usually a sign you are overcomplicating the structure.

Cascade layers work well with design tokens, which are custom properties. Tokens are defined in a base layer, and components read them via var(). Because custom properties inherit and cascade, a component can override a token locally without affecting other components.

The fallback for @layer is a feature query: @supports not (at-rule(@layer)). But the fallback is not pretty: you need to write unlayered styles with higher specificity or !important to achieve the same priority. That is fragile, and it is a good reason to check your browser support targets before adopting layers.

Component Encapsulation: Shadow DOM and the Limits of Style Isolation

True Isolation In The Browser

The term component encapsulation means you want styles that cannot leak out and cannot leak in. Shadow DOM is the only browser-native mechanism that provides both directions of containment. When you attach a shadow root to an element, the styles inside that shadow root do not affect the light DOM, and the light DOM styles do not affect the shadow root, except for inherited properties like color and font.

The syntax is simple in practice:

const host = document.getElementById('my-component');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>.button { /* scoped styles */ }</style><button class="button">Submit</button>`;

The styles are scoped to the shadow root. There is no build step, no hashing, no naming convention. Shadow DOM is widely available as of 2026, per caniuse.com and MDN browser compatibility data. The baseline for the attachShadow API is marked “Widely Available” as of September 2025, which means it is safe to use without a fallback for the majority of browsers.

Failure Modes And Surprises

The failure modes are specific. The first: assuming ::part() penetrates nested shadow roots. It does not. ::part() exposes a single shadow root’s parts to the outer document, but it only reaches one level deep. If you have a shadow root inside a shadow root, you cannot style the inner part from outside the outer root. You need to expose the inner part through the outer root’s ::part() as well.

The second mistake: duplicating global styles inside every shadow root. If you copy your reset or your design tokens into each shadow root, you are shipping duplicate CSS. The correct approach is to use adoptedStyleSheets, which lets you share a CSSStyleSheet object across multiple shadow roots, or to rely on CSS custom properties for theming. Custom properties inherit into shadow roots from the host, so you can define tokens on the host and read them inside the shadow root.

Shadow DOM is not a replacement for @scope or CSS Modules. It is a heavier mechanism with real costs: it changes the rendering tree, it can affect accessibility if you do not use the right ARIA roles, and it adds complexity to styling from outside. If you only need to prevent style leakage, @scope or CSS Modules is lighter. If you need full isolation, for a third-party widget, for example, shadow DOM is the right answer.

The most common mistake with shadow DOM styles is treating it as a silver bullet. It is not. It protects against leakage, but it does not protect against inherited properties, which still pass through the shadow boundary. If you set color: red on the host, the shadow root’s text will be red unless you override it. That is by design, but it surprises people.

Naming Conventions and Co-Location: The Old Guard Still Works

If you cannot use a build step, shadow DOM, or @scope, naming conventions are the only tool left. BEM is the most common. The rule is: every class name encodes its block, element, and modifier. .card__title--large tells you the block is card, the element is title, and the modifier is large. That makes collisions unlikely, because the block name is unique across the project.

Co-location is the practice of putting the CSS file next to the component file, rather than in a global stylesheet. A directory structure like components/button/button.js and components/button/button.css makes it obvious which styles belong to which component. That is a significant improvement over a single styles.css with thousands of lines, where you cannot tell which piece a rule belongs to.

The failure mode of naming conventions is that they depend on human discipline. A developer can write .card-title instead of .card__title--large, and the system breaks silently. BEM does not enforce anything; it is a convention. The other failure is that BEM does nothing about dead CSS. You still have to audit which classes are used, because nothing removes unused rules automatically.

The strength of naming conventions is that they work everywhere. No build step, no browser support requirements, no shadow DOM. If you are maintaining a legacy project, or if you are working in a context where you cannot change the build, BEM is the reliable answer. It is not glamorous, but it is honest.

Utility Classes and the Trade-Off They Bring

Utility classes, .mt-4, .text-center, .flex, are a different approach to component styling. Instead of writing component-specific rules, you compose utilities in the markup. That is fast, and it avoids the need for scoping, because each utility is a single-purpose class with a global name.

The problem is that utility classes do not encapsulate anything. They are global by definition. A .text-center class can apply to anything. That means you are back to the global namespace problem, but at a finer granularity. The advantage is that utilities are reusable, so you write them once and use them everywhere. The disadvantage is that the HTML becomes noisy, and the design system’s vocabulary is spread across every component’s markup.

There is a performance angle. Utility classes compress well with gzip and brotli because they repeat. The string .text-center appearing many times is exactly the repetition those algorithms exploit. So a utility-heavy stylesheet can be smaller than a component-heavy stylesheet with many unique class names.

But utility classes do not solve the accumulation problem. If you add a utility class and then stop using it, it stays in the stylesheet until you remove it manually or run a tool that scans your markup. That is the same problem as BEM, but with more classes.

The real question is whether utilities are part of your component system or a layer on top. Many design systems use both: utilities for spacing and layout, and bespoke classes for specific patterns. That works if you define the boundary clearly. The failure is when utilities are used for things that should be component-specific, like colors or typography, because then the component’s visual identity is scattered across the markup instead of in one place.

Container Queries and Style Queries: Scoping to Size, Not Just to DOM

Container queries extend the scoping idea from the DOM tree to the size of a container. Instead of responding to the viewport, a container query responds to the nearest ancestor with a container-type declared.

.card {
  container-type: inline-size;
}

@container (min-width: 400px) {
  .card__title {
    font-size: 1.5rem;
  }
}

This is a form of component scoping, because the styles are conditional on the component’s own size. The most common mistake is forgetting to declare container-type on the container. Without it, the query has no container to measure, and it does nothing.

Style queries are a newer feature. They respond to the computed value of a custom property on the container, not to a size measurement.

.card {
  --variant: primary;
}

@container style(--variant: primary) {
  .card__title {
    color: blue;
  }
}

The distinction: size container queries measure a dimension; style queries check a value. They are different conditions, and they cannot be interchanged. Style queries shipped later than size queries, and there are interop gaps in older versions of Safari that shipped size queries but not style queries.

The practical advice: use size queries for layout decisions that depend on available space. Use style queries for variant logic that would otherwise require a modifier class. The failure is treating style queries as a replacement for @scope. They are not. @scope constrains matching to a DOM subtree; style queries condition on a value. They are orthogonal.

Container units, cqw, cqh, cqi, cqb, give you lengths relative to the container. They have interop bugs in some older Safari versions, so test before relying on them. The safest approach is to use container queries for layout and reserve units for cases where you need a measurement relative to the container.

CSS Nesting and the & Token: Writing Less, but with Caveats

CSS nesting, shipping in all engines since 2023-2024, lets you write nested rules without a preprocessor:

.card {
  padding: 1rem;
  & .title {
    font-size: 1.25rem;
  }
}

The & token refers to the parent selector. This is not just syntactic sugar; it changes how you organise styles, because you can co-locate a piece’s rules inside its root selector. That is a form of scoping, in the sense that the styles are visually grouped, but it does not constrain matching. The nested selector .card .title still matches any .title inside .card, anywhere in the document.

The failure mode: the relaxed parsing behaviour, allowing element selectors without &, shipped later and inconsistently. Some valid nested CSS is rejected by older implementations that shipped the earlier spec text. Specifically, & p is fine, but p & is not, and element selectors without & are only allowed in newer versions. The safe rule is to always use & explicitly.

The fallback for nesting is a feature query: @supports not (selector(&)). But the fallback means writing flat selectors, which duplicates the nested structure. If you are using a preprocessor like Sass, the output is already flattened, so you may not need native nesting at all. The benefit of native nesting is that you can drop the preprocessor for this feature, but you still need to handle variables, mixins, and functions.

The second mistake: using & in a position that creates an invalid compound selector. For example, & .child when the parent is a pseudo-element like ::before produces ::before .child, which is invalid. Always test with a linter or in the browser.

Dead CSS and Tree Shaking: What Actually Works

The phrase dead CSS means rules that are never matched by any element in your document. It accumulates because CSS files are static, and nothing removes rules automatically. Tree shaking is a JavaScript concept: the bundler removes unused module exports. CSS does not work that way, because CSS has no module system in the browser.

Tools like PurgeCSS or Tailwind’s purge mechanism scan your markup and remove selectors that do not appear. That works well for utility classes, because the class names appear as strings in your HTML or JavaScript. It works less well for dynamic class names that are constructed at runtime, like class="card card--" + variant. If the tool cannot see the full class name, it may remove the rule.

The failure case is when you use a CSS-in-JS library that generates styles at runtime. Those styles are not in a static file, so purging tools cannot see them. The trade-off is that runtime styles are only generated when the component is rendered, which means dead CSS is less likely, but you pay a runtime cost.

For co-located CSS files with BEM, the solution is manual auditing or a tool that understands the naming convention. Some tools can extract class names from your source and match them against the stylesheet. But none of this is automatic. The honest answer is that dead CSS is a maintenance problem, and the best prevention is to delete a component’s CSS file when you delete the component, and to review the stylesheet periodically.

Cascade layers do not help with dead CSS. @scope does not help either. Only tools that scan your actual markup can remove unused rules, and those tools have limitations. The most reliable approach is to keep your component CSS small, co-located, and delete it when the component goes.

The @scope Failure Mode: What It Does Not Do

Selector Containment Is Not Isolation

The most common mistake with @scope is using it as a replacement for component-level scoping when it only limits selector reach, not style leakage from outer scopes. That is a subtle but critical distinction. Consider this:

@scope (.card) {
  .title { color: blue; }
}

This prevents .title from matching outside .card. But it does not prevent a global rule like h3 { color: red; } from matching a .title that is also an h3 inside the card. The global rule still applies, because @scope does not create a containing block for the cascade. It constrains where your rules can match, not what other rules can match your elements.

The second mistake: expecting @scope to create a new stacking context or a containing block. It does not. If you set z-index inside a @scope, it behaves according to the normal stacking rules. If you set position: fixed, it is relative to the viewport, not the scope root. That is a common source of confusion.

Another failure: @scope does not limit the cascade from outside. If you have a rule with higher specificity that targets an element inside the scope, it wins, because @scope does not change priority. It only changes matching. This is different from shadow DOM, where the shadow boundary blocks external rules entirely. @scope is a softer containment.

Combining @scope With @layer

The fallback is to combine @scope with @layer. Put your component styles in a later layer so they win over base styles, and use @scope to limit matching. That gives you both priority and containment. But you need to be careful: @scope and @layer are orthogonal. @layer controls ordering; @scope controls matching. They are not interchangeable.

If you need true encapsulation, where external styles cannot influence your component, use shadow DOM. @scope is not a substitute. It is a lighter tool for a narrower job.

FAQ

Does @scope work in all browsers?

No. As of 2026, @scope is supported in Chromium, Firefox, and Safari, but the exact version varies. Check caniuse.com for your target range. For older browsers, use the @supports fallback with BEM or CSS Modules.

Can I use @scope with CSS Modules?

Yes. They solve different problems. CSS Modules hash class names at build time; @scope limits selector reach at runtime. You can use both in the same project, but you rarely need to.

What is the difference between @scope and @layer?

@scope constrains which elements a selector can match, based on the DOM tree. @layer controls the order in which rules win, based on priority. They address different dimensions of the cascade.

Does shadow DOM replace @scope?

No. Shadow DOM provides full encapsulation, styles cannot leak in or out. @scope only prevents your rules from matching outside a subtree. Use shadow DOM for third-party widgets; use @scope for internal components.

What is the best way to eliminate dead CSS?

For utility classes, use a purging tool that scans your markup. For component styles, delete the CSS file when you delete the component, and audit periodically. No CSS feature removes unused rules automatically.

What It Costs and When to Choose What

Here is the practical summary. If you have a build step, CSS Modules give you hashed class names and automatic dead CSS removal when a module is not imported. The cost is that you are tied to a bundler, and you must remember the composes caveat. If you have no build step, @scope gives you native containment with no tooling, but you must write a fallback for older browsers, and you must manually manage dead CSS. If you need full isolation, shadow DOM is the only option, but it changes the rendering tree and adds complexity. If you are maintaining a legacy project, BEM is the reliable fallback that works everywhere.

The honest caveat is that no single approach solves all problems. @scope is the most promising because it is native and simple, but it does not protect against receiving unwanted styles from outside, and it does not eliminate dead CSS. The engineering decision is not about picking the newest feature; it is about understanding which failure mode you can live with. The global-namespace problem is not solved by any one tool. It is managed by a combination of naming, layers, scoping, and auditing. That is the reality, and it is better to know it than to assume a silver bullet exists.The @scope fallback doubles your maintenance burden because you must write a duplicate set of unscoped rules, and that cost is rarely mentioned in the hype.”