CSS Architecture at Scale

A 200-line stylesheet needs no architecture. A 20,000-line one shared by forty pages does: without explicit rules about naming, layering, and specificity, every fix fights the last one. This lesson is about the decisions that keep a stylesheet maintainable — in plain CSS, no preprocessor required.

The problem shape

Why stylesheets rot

Three forces compound over time. Specificity creep: each fix adds a more specific selector to beat the last one. Dead code: deleting a class feels risky because nothing tells you what still uses it. Location opacity: the rule that styles a button could be in any of six files, under any of four selectors. Every convention below attacks one of these.

Naming conventions

The idea behind BEM — and its variants

Block Element Modifier (BEM) is the most widespread naming contract: each class announces what it belongs to and what it is for, which makes the connection between markup and styles greppable and deletions safe.

/* Block: a standalone component */
.card { padding: 1rem; }

/* Element: a part of the block, named with __ */
.card__title { font-weight: 600; }
.card__body { color: var(--color-surface-ink); }

/* Modifier: a variant of the block or element, named with -- */
.card--featured { border-color: var(--color-action); }
.card__title--small { font-size: 0.875rem; }
  • BEM (above): explicit, verbose, greppable. A solid default for teams.
  • Utility-first: skip semantic classes; compose styles from many single-purpose classes in the markup (Tailwind's model — see the Ecosystem lessons).
  • No convention: viable only with strict cascade discipline (layers + low-specificity selectors), typical for small design systems with deep component encapsulation.

The convention matters less than its two invariants: a reader can find where a style is defined, and a class can be deleted when its markup is gone. Pick one, write it down, enforce it in review.

Cascade layers as architecture

Ordering the codebase itself

@layer (introduced in the Cascade lesson) is the modern answer to specificity creep. Declare the layer order once, early, and every file slots into a known priority:

/* The declaration order is the ONLY thing that sets layer priority.
   Later @layer statements add files to these layers, never reorder them. */
@layer reset, tokens, base, layout, components, utilities;

@layer reset      { *, *::before, *::after { box-sizing: border-box; } }
@layer tokens     { :root { --space-3: 1rem; } }        /* design tokens */
@layer base       { body { line-height: 1.6; } }         /* element defaults */
@layer layout     { .page { display: grid; } }           /* page shells */
@layer components { .card { padding: var(--space-3); } }
@layer utilities  { .visually-hidden { /* ... */ } }     /* highest: always win */

With layers in place, specificity only needs to rank rules within a layer — and utilities in the top layer beat any component without a single !important. Unlayered overrides remain available for one-off patches, deliberately ranked above everything.

The specificity budget

Layering replaces the older, weaker guardrails — but the habits still help: keep selectors to one class where possible, avoid IDs in selectors entirely, and reserve !important for the utilities layer. If a fix needs two classes and a parent chain to win, the fix belongs in a higher layer, not in a longer selector.

Organizing files

A file layout that scales

/* styles.css - the entry point: order IS the layer contract */
@layer reset, tokens, base, layout, components, utilities;
@import url("reset.css") layer(reset);
@import url("tokens.css") layer(tokens);
@import url("base.css") layer(base);
@import url("layout.css") layer(layout);
/* components: one file per component, imported where used */
@import url("components/card.css") layer(components);
@import url("utilities.css") layer(utilities);

The layer() import annotation assigns a file to a layer at import time, so a bundler-less project still gets deterministic ordering. Within components/, one file per component keeps deletions local: delete the component, delete its file.

Deleting dead CSS safely

Workflow that works: grep the class name across the templates and scripts (not just HTML — class names are constructed in JavaScript too), remove the rule, run the visual smoke test, commit the deletion separately. Tooling (Stylelint's no-unused-style plugins, coverage tools) helps at scale — see References.

Practice: architect a stylesheet

The task

Take a multi-page project you have styled and reorganize it into the layered structure above, one step at a time, verifying visually after each step.

The checklist

  • Declare the layer order once and move the reset into layer(reset).
  • Rename one component's classes to a BEM contract and confirm no other rule needed changing.
  • Delete one dead rule end-to-end: grep, delete, smoke test, commit.
  • Move an !important fix into the utilities layer and remove the importance.