CSS Architecture
Date: 2026-09-27
Every CSS methodology answers one problem: all CSS is global, so any rule can affect any element, and nobody can prove a rule is safe to delete. Each approach limits that reach — by naming convention, by generated class names, by colocating styles with components, by native scoping — and trades something away to do it.
CSS architecture is the set of conventions and tools a codebase uses to decide where styles live, how they’re named and scoped, and how conflicts resolve. It exists because CSS has one global namespace and a cascade: without a system, a stylesheet only grows, because deleting a rule is a guess — The Cascade and Specificity.
The problem, concretely
year 1 .title { font-size: 2rem; } one component uses it
year 2 .sidebar .title { font-size: 1.25rem; } override for a context
year 3 .promo .title { font-size: 1.5rem !important } someone lost a fight
year 4 nobody knows which pages use which, so nothing is ever deleted
Three separate failures, and every approach below addresses some of them:
- Leakage — a rule written for one component matches elements in another
- Specificity escalation — overrides win by out-selecting, and it only goes one way
- Dead code — unused rules can’t be identified, so they’re kept forever
The approaches
| Approach | How it limits reach | Optimises for | Costs |
|---|---|---|---|
| BEM (Block, Element, Modifier) naming | Convention: .card__title--large is unique by name, and every selector is one class | Flat specificity, readable intent, no tooling | Discipline only. Nothing enforces it, and verbose names |
| ITCSS (Inverted Triangle CSS) | Files ordered from generic and low-specificity to specific and high | Predictable override order | Order is by convention. @layer now does this natively |
| CSS Modules | Build step renames .title to .Card_title__x7f2, unique per file | Real isolation with plain CSS syntax | Needs a bundler. Global styles need an escape hatch |
| CSS-in-JS | Styles written in the component file, class names generated | Colocation, styles driven by props | Runtime libraries add JavaScript and style-injection cost. See below |
| Utility-first | No component CSS. Composed from single-purpose classes | Bounded stylesheet, safe deletion | Markup verbosity; see Utility-First vs Semantic CSS |
| Native scoping | @layer for order, @scope for reach, shadow DOM for hard isolation | No tooling at all | Newer; @scope support varies [CHECK: current @scope support] |
The utility-versus-semantic argument, and why components make most of it irrelevant, is its own note: Utility-First vs Semantic CSS.
What BEM looks like
The convention with the most staying power, because it needs nothing but agreement:
.card {} /* block: a standalone component */
.card__title {} /* element: a part of the block, meaningless outside it */
.card--featured {} /* modifier: a variant of the block */
.card__title--large {} /* modifier on an element */
/* never: .card .title — nesting reintroduces specificity and leakage */Every selector is exactly one class, so specificity is flat (0,1,0) everywhere and order is the only tie-break. That flatness is the real benefit; the naming is just how it’s enforced — Variants and Modifiers.
CSS-in-JS: runtime vs build time
The category splits in two, and the split matters more than the syntax:
RUNTIME (styles generated in the browser)
component renders → JS serialises styles → injects a <style> tag
✗ extra JS in the bundle · work on every render · style recalculation
✗ awkward with server components and streaming [CHECK: current state per library]
ZERO-RUNTIME (styles extracted at build)
same authoring in the component file → build emits static .css
✓ plain CSS in production, no runtime cost
The runtime form’s costs land on the metrics that matter — JavaScript weight and main-thread work — which is why the move has been toward extraction — Render-Blocking Resources, Bundle Analysis.
Choosing
The underlying decisions, whatever the label:
- What’s the reuse unit? If it’s a framework component, any colocated approach (Modules, zero-runtime CSS-in-JS, utilities) works. If it’s server templates or a CMS theme with no component layer, naming conventions and layers are what’s available
- Is there a build step? Modules and CSS-in-JS need one. BEM and native scoping don’t
- Where do values come from? Every approach degrades the same way when colours and spacing are typed freely. A token set matters more than the methodology — Design Tokens in Practice
- Consistency beats choice. A codebase half on one approach and half on another has both sets of failure modes
Failure modes
- Global styles creeping back. Every scoped system needs a place for resets, typography and third-party overrides. Without a deliberate one (a low
@layer), they end up scattered — Cascade Layers - Scoping that only stops leakage in one direction. Modules stop a component’s rules escaping, but a global
h2 {}still reaches in - Dead code in the “safe” system. Scoped styles attached to a component that’s no longer rendered still ship until the component file is deleted
- Architecture as a substitute for a design system. Perfectly scoped CSS with forty distinct greys is still a mess
Where it connects
- Cascade Layers — the native form of ITCSS’s ordering idea
- Web Components — shadow DOM as the hard-isolation end of the range
- Design Systems — what the architecture is ultimately in service of