Tags: web-dev concept

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

ApproachHow it limits reachOptimises forCosts
BEM (Block, Element, Modifier) namingConvention: .card__title--large is unique by name, and every selector is one classFlat specificity, readable intent, no toolingDiscipline only. Nothing enforces it, and verbose names
ITCSS (Inverted Triangle CSS)Files ordered from generic and low-specificity to specific and highPredictable override orderOrder is by convention. @layer now does this natively
CSS ModulesBuild step renames .title to .Card_title__x7f2, unique per fileReal isolation with plain CSS syntaxNeeds a bundler. Global styles need an escape hatch
CSS-in-JSStyles written in the component file, class names generatedColocation, styles driven by propsRuntime libraries add JavaScript and style-injection cost. See below
Utility-firstNo component CSS. Composed from single-purpose classesBounded stylesheet, safe deletionMarkup verbosity; see Utility-First vs Semantic CSS
Native scoping@layer for order, @scope for reach, shadow DOM for hard isolationNo tooling at allNewer; @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