CSS architecture — BEM, utility classes, and CSS-in-JS trade-offs

Every CSS architecture approach is really answering the same underlying question — how do you keep styles from unpredictably colliding as a codebase grows — with a different trade-off between naming discipline, file organization, and tooling.

Intermediate

4 min read

The problem every approach below is actually solving

/* Two completely unrelated components, written independently, both reach for ".card" */
.card { padding: 16px; }        /* meant for a product card */
.card { display: flex; }        /* meant for an unrelated settings card, added months later */
/* Both rules apply to BOTH components now — an unintended collision, purely from naming */

Because CSS class selectors have global scope by default — any .card rule anywhere in any loaded stylesheet applies to any element with class="card" anywhere on the page — a growing codebase with many contributors will eventually produce two components that independently reach for the same short, obvious class name, and collide. Every architecture pattern in this lesson is a different strategy for preventing that collision at scale, each with a real trade-off in verbosity, tooling, or flexibility.

BEM: encoding structure and state directly into the class name

.card { }               /* Block — a standalone component */
.card__title { }        /* Element — a part of that block, joined with __ */
.card--featured { }     /* Modifier — a variation of the block, joined with -- */
.card__title--large { } /* an element WITH a modifier */

BEM (Block, Element, Modifier) makes collisions structurally unlikely by encoding the component's full context directly into every class name — .card__title can't accidentally collide with an unrelated .title class elsewhere, because the full name is specific to this exact block. The trade-off is verbosity: names get long, and the discipline has to be followed consistently by everyone touching the codebase, with nothing in the tooling itself enforcing it.

Utility-first (Tailwind-style): tiny, single-purpose classes composed in the markup

<div class="flex items-center gap-4 rounded-lg bg-white p-4 shadow-sm">
  <!-- each class does ONE thing; the component's actual look is composed entirely in the markup -->
</div>

Instead of writing a new custom class per component, utility-first CSS provides a large, pre-built set of tiny classes (flex, p-4, rounded-lg) that get composed directly in the markup — collisions become nearly impossible because there's no custom component-specific naming happening at all, and there's very little bespoke CSS left to write or maintain. The trade-off: the markup itself becomes visually dense and carries styling information that used to live in a separate stylesheet, which some teams find harder to scan, and others find faster to work in once the utility vocabulary is familiar.

CSS Modules and CSS-in-JS: scoping enforced by the build tool, not by naming discipline

// CSS Modules — import styles.card is REWRITTEN at build time to a guaranteed-unique class name
import styles from "./Card.module.css";
function Card() { return <div className={styles.card}>...</div>; }
// The actual class in the compiled output might be "Card_card__a1b2c" — collision-proof by construction

CSS Modules (and CSS-in-JS libraries with similar scoping) sidestep the naming-discipline problem entirely: the build tool automatically generates a unique class name per component at compile time, so two components can both write .card in their own source files and never collide, because the tooling guarantees the compiled output never matches. This trades BEM's manual discipline and utility-first's markup density for a build-step (or runtime, for some CSS-in-JS approaches) dependency — genuinely collision-proof, at the cost of the styling no longer being plain, portable CSS that works with zero tooling.

There's no universally correct choice — the trade-off is real in every direction

BEM needs no build tooling but relies entirely on team discipline being followed consistently. Utility-first needs very little custom CSS but changes what markup looks like to review. Scoped approaches remove the collision risk structurally but add a build dependency. Real production codebases reach for different points on this spectrum depending on team size, tooling constraints, and how much the team values plain, portable CSS versus compile-time guarantees — there isn't a single "correct" answer independent of those constraints.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What underlying problem do BEM, utility-first CSS, and CSS Modules all address, in different ways?

2. How does BEM prevent naming collisions, and what's its real trade-off?

3. How do CSS Modules solve the collision problem differently from BEM?