CSS Architecture
🏗️ Chapter 11 — CSS Architecture: BEM and Utility-First Thinking
CSS has no enforced structure — you can name a class anything, nest selectors anywhere, and override anything with specificity. For a personal project, that freedom is fine. On a team or a codebase that grows over months, it becomes a liability: specificity wars, fear of deleting "dead" code that might secretly be used somewhere, and styles that only work because of the exact order they were written in. CSS architecture methodologies provide naming conventions and structural rules that tame this chaos.
1 — The Problem with Unstructured CSS
Imagine three developers separately adding styles for a button over six months:
/* Developer A, Month 1 */
button { background: blue; } /* 0,0,1 */
/* Developer B, Month 3 — needed it red in the sidebar */
.sidebar button { background: red; } /* 0,1,1 */
/* Developer C, Month 6 — needs it blue again in sidebar */
.sidebar .btn { background: blue !important; } /* nuclear option */
/* Now nobody can safely change anything without checking
every combination of .sidebar, button, .btn, !important... */
The result is CSS that only works because of its order and because of implicit knowledge of what elements exist in the HTML. Architecture methodologies eliminate these problems before they start.
Specificity scores of common selectors
2 — BEM: Block Element Modifier
BEM, developed at Yandex in 2005, solves the specificity problem with a strict naming convention. Every CSS rule targets exactly one class, keeping specificity at 0,1,0 everywhere. The naming convention encodes the component structure into the class names themselves, making the HTML self-documenting.
The three parts
/* BLOCK — a standalone, reusable component */
.card { } /* the card as a whole */
.nav { }
.button { }
.modal { }
/* ELEMENT — a child of a block, separated by __ (double underscore) */
.card__image { } /* .card's image */
.card__title { }
.card__footer { }
.nav__item { }
.modal__overlay { }
/* MODIFIER — a variant or state, separated by -- (double dash) */
.card--featured { } /* a featured card */
.card--compact { } /* a smaller card */
.button--primary { }
.button--disabled{ }
.nav__item--active{ } /* element modifier */
<div class="card card--featured"> — not <div class="card--featured"> alone. The modifier only overrides the parts that change.How CSS Architecture Saves Teams
Naming conventions turn chaotic stylesheets into predictable, maintainable systems.
BEM rules and anti-patterns
/* ✓ GOOD — flat specificity, predictable */
.card { border-radius: 8px; } /* 0,1,0 */
.card__title { font-size: 1.2rem; } /* 0,1,0 */
.card--featured { border-color: gold; } /* 0,1,0 */
/* ✗ ANTI-PATTERN: nesting element inside block selector */
.card .card__title { } /* specificity is now 0,2,0 — harder to override */
/* ✗ ANTI-PATTERN: three levels deep */
.card__body__paragraph { } /* wrong — use .card__text instead */
/* ✗ ANTI-PATTERN: element selector inside BEM */
.card h2 { } /* breaks when h2 becomes h3 or a div */
/* ✗ ANTI-PATTERN: BEM + utility chaos */
.card--featured.is-active.has-image { } /* two different systems colliding */
/* ✓ GOOD: The MIX pattern — one element, two blocks */
/* <div class="card layout__item"> */
/* .card controls the card's appearance */
/* .layout__item controls how it sits in its container */
<!-- Navigation component -->
<nav class="nav">
<ul class="nav__list">
<li class="nav__item">
<a class="nav__link" href="/">Home</a>
</li>
<li class="nav__item nav__item--active">
<a class="nav__link" href="/about">About</a>
</li>
<li class="nav__item">
<a class="nav__link nav__link--cta" href="/contact">Contact</a>
</li>
</ul>
</nav>
<!-- The HTML alone tells you: nav (block), nav__item (elements),
--active and --cta (modifiers). No reading the CSS file needed. -->
3 — Utility-First CSS
Utility-first takes the opposite approach: instead of naming components and writing CSS for them, you compose dozens of single-purpose classes directly in the HTML. Every class does exactly one thing: .flex, .p-4, .text-blue-500, .rounded-lg, .hover:shadow-md. Tailwind CSS popularised this approach and is now the most-downloaded CSS framework.
<!-- BEM — semantic class name, styling in CSS file -->
<button class="button button--primary button--large">
Submit
</button>
<!-- Utility-first (Tailwind) — styling in HTML itself -->
<button class="bg-blue-600 text-white px-6 py-3 rounded-lg
font-semibold text-base hover:bg-blue-700
active:scale-95 transition-all">
Submit
</button>
<!-- Tailwind component abstraction — best of both:
extract to a component in your JS framework -->
<PrimaryButton>Submit</PrimaryButton>
<!-- The component internally uses utilities but hides them -->
<button class="button button--primary">Primary</button>
<button class="button button--secondary">Secondary</button>
<button class="button button--danger">Danger</button>
<button class="button button--ghost">Ghost</button>
Writing your own utility classes
/* Spacing utilities (u- prefix marks utility) */
.u-mt-0 { margin-top: 0; }
.u-mt-1 { margin-top: 0.5rem; }
.u-mt-2 { margin-top: 1rem; }
.u-mt-4 { margin-top: 2rem; }
/* Display utilities */
.u-flex { display: flex; }
.u-grid { display: grid; }
.u-hidden{ display: none; }
/* Text utilities */
.u-truncate {
overflow: hidden;
text-overflow:ellipsis;
white-space: nowrap;
}
.u-sr-only { /* screen-reader only */
position: absolute;
width: 1px;
height: 1px;
clip: rect(0,0,0,0);
overflow: hidden;
}
4 — Other Methodologies (Overview)
5 — The Hybrid Approach (Most Practical)
Most real projects land somewhere between pure BEM and pure utility-first. A practical hybrid: BEM for components, utilities for layout and one-offs.
<!-- Component class handles the card's appearance -->
<!-- Utility classes handle spacing, layout, one-offs -->
<article class="card card--featured u-mt-4">
<h2 class="card__title u-truncate">Long Title That Gets Cut Off</h2>
<p class="card__excerpt">Excerpt...</p>
</article>
<!-- Layout shell is utility-driven -->
<div class="u-grid u-grid-cols-3 u-gap-4">
<article class="card">…</article>
<article class="card card--featured">…</article>
</div>
6 — CSS Custom Properties as Architecture
Regardless of which naming methodology you choose, CSS custom properties (variables) provide the foundation that makes any system consistent. Design tokens — the atoms of a design system — live in :root and are consumed everywhere.
/* Layer 1: Raw values (primitive tokens) */
:root {
/* Color palette */
--blue-500: #4f8ef7;
--blue-600: #3a7ae5;
--gray-100: #f3f4f6;
--gray-900: #111827;
/* Layer 2: Semantic tokens (what things ARE) */
--color-primary: var(--blue-500);
--color-primary-hover:var(--blue-600);
--color-surface: var(--gray-100);
--color-text: var(--gray-900);
/* Spacing scale */
--space-1: clamp(0.5rem, 1vw, 0.75rem);
--space-2: clamp(1rem, 2vw, 1.5rem);
--space-4: clamp(2rem, 4vw, 3rem);
/* Typography */
--text-base: clamp(1rem, 1.5vw + 0.25rem, 1.25rem);
--text-lg: clamp(1.25rem, 2.5vw, 1.75rem);
/* Component tokens (consume semantic tokens) */
--card-padding: var(--space-2);
--card-radius: 8px;
--card-border: 1px solid var(--color-border);
}
/* Dark mode — just re-map the semantic tokens */
@media (prefers-color-scheme: dark) {
:root {
--color-surface: var(--gray-900);
--color-text: var(--gray-100);
/* --blue-500 stays the same — only semantic tokens change */
}
}
/* Components reference tokens — never raw values */
.card {
background: var(--color-surface);
color: var(--color-text);
padding: var(--card-padding);
border-radius:var(--card-radius);
}
/* Swap themes by re-mapping :root tokens — no component CSS changes */
7 — Choosing an Approach
| Context | Recommendation | Why |
|---|---|---|
| Small personal project | Whatever feels natural | Architecture pays off at scale, not worth the overhead on solo projects |
| Team project (no framework) | BEM + design tokens | Predictable naming prevents cross-developer conflicts |
| React / Vue / Svelte project | CSS Modules or utility framework | Component scoping solves the global namespace problem; Tailwind integrates naturally |
| Large-scale site / CMS | ITCSS or SMACSS + BEM | Layer-based architecture handles hundreds of components cleanly |
| Design system / component library | Design tokens + BEM | Tokens provide consistency; BEM makes components self-contained |
| Rapid prototyping | Utility-first (Tailwind) | No context switching between HTML and CSS — fastest iteration speed |
8 — Quick Reference
| Concept | Syntax | Rule |
|---|---|---|
| Block | .card | Standalone reusable component |
| Element | .card__title | Part of a block, double underscore |
| Modifier | .card--featured | Variant or state, double dash — always used alongside base class |
| Element modifier | .card__title--truncated | State of an element — keep depth to two levels maximum |
| MIX | class="card layout__cell" | One HTML element plays two roles — different concerns, different classes |
| Utility | .u-truncate | Single-purpose class; prefix to distinguish from components |
| Design token | --color-primary | Semantic custom property; always reference tokens not raw values |
✏️ Exercises
Good architecture is a habit, not a one-time choice. These exercises build the muscle memory of naming things intentionally.
.post). Elements use .post__*. Go only two levels deep — if a tag pill contains an icon, that icon is .post__tag-icon not .post__tags__tag__icon.<article class="post post--featured">
<div class="post__hero">
<img class="post__image">
<span class="post__category">Technology</span>
</div>
<div class="post__content">
<h1 class="post__title">Title</h1>
<div class="post__meta">
<span class="post__author">Jane</span>
<time class="post__date">Jun 2026</time>
<span class="post__read-time">5 min</span>
</div>
<div class="post__body">…</div>
<ul class="post__tags">
<li class="post__tag">CSS</li>
<li class="post__tag post__tag--highlight">Architecture</li>
</ul>
<div class="post__share">
<button class="post__share-btn">Twitter</button>
<button class="post__share-btn">LinkedIn</button>
</div>
</div>
</article>
!important. Rewrite it as flat BEM rules — all targeting a single class at 0,1,0 specificity. Add modifier rules where the original had context-based overrides./* ✗ Original messy CSS */
#header nav ul li a { color: white; }
#header nav ul li.active a{ color: gold !important; }
.sidebar nav ul li a { color: black; }
/* ✓ BEM rewrite */
.nav__link { color: white; }
.nav__link--active { color: gold; }
.nav--sidebar .nav__link { color: black; }
/* Or even better — use a modifier on the link itself: */
.nav__link--dark { color: black; }
/* HTML: <a class="nav__link nav__link--dark"> in sidebar context */
--color-primary, --color-danger, --color-surface, --color-text, --color-border); (c) a dark-mode override using @media (prefers-color-scheme: dark) that only re-maps the semantic tokens; (d) a spacing scale using clamp() from --space-1 to --space-6.--color-surface flips from a light to dark shade./* (a) Primitive palette */
:root {
--blue-300: #7ab8ff; --blue-500: #4f8ef7; --blue-700: #2a5ab0;
--red-300: #f08080; --red-500: #e05555; --red-700: #a02020;
--gray-100: #f3f4f6; --gray-500: #6b7280; --gray-900: #111827;
/* (b) Semantic tokens — light mode defaults */
--color-primary: var(--blue-500);
--color-primary-hover:var(--blue-700);
--color-danger: var(--red-500);
--color-surface: var(--gray-100);
--color-text: var(--gray-900);
--color-text-muted: var(--gray-500);
--color-border: var(--gray-500);
/* (d) Fluid spacing scale */
--space-1: clamp(0.25rem, 0.5vw, 0.5rem);
--space-2: clamp(0.5rem, 1vw, 0.875rem);
--space-3: clamp(0.875rem,1.5vw, 1.25rem);
--space-4: clamp(1.25rem, 2.5vw, 2rem);
--space-5: clamp(2rem, 4vw, 3rem);
--space-6: clamp(3rem, 7vw, 5rem);
}
/* (c) Dark mode — remap semantic tokens only */
@media (prefers-color-scheme: dark) {
:root {
--color-primary: var(--blue-300); /* lighter in dark mode */
--color-surface: var(--gray-900);
--color-text: var(--gray-100);
--color-border: #2a2d3a;
/* Primitive palette unchanged — only semantics flip */
}
}
!important; (c) any selectors that depend on HTML structure (descendant selectors); (d) any duplicate property declarations that differ only in context. Write a refactored version of the most problematic section using either BEM naming or design tokens, and explain in a comment block at the top why each change was made./*
AUDIT FINDINGS:
✗ .sidebar .nav li a — specificity 0,2,2, context-dependent
✗ #main .btn — specificity 1,1,0, ID makes it unmatchable
✗ !important on color — symptom of a specificity war
✗ margin repeated 4 times for different contexts
REFACTOR DECISIONS:
- Replaced .sidebar .nav li a with .nav__link--sidebar (0,1,0)
- Replaced #main .btn with .btn--main-variant (0,1,0)
- Removed !important by fixing the selector chain
- Extracted margin to a --card-gap token, used everywhere
*/
/* Before */
#sidebar .nav li a { color: blue !important; }
/* After */
:root { --nav-link-sidebar-color: var(--color-primary); }
.nav__link--sidebar { color: var(--nav-link-sidebar-color); }