#React#TypeScript

CSS Architecture Patterns: BEM, CSS Modules, and CSS-in-JS

CSS gets messy at scale: naming collisions, global pollution, specificity wars. This article clarifies the mainstream CSS architecture patterns: BEM (naming conventions), CSS Modules (local scoping), CSS-in-JS (styled-components/emotion), and the rise of Tailwind/atomic CSS, with guidance on when and how to choose.

Key Points

  • Topic Focus: React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Throw together a demo page and the CSS looks fine no matter how you write it; build a real application and the CSS turns into a mess no matter how you write it. A year or two in, the button class has been overridden eight times by five people, the main class name appears on both the login page and the homepage yet looks completely different, changing one padding drags in who-knows-how-many pages, and !important is everywhere yet still can't win the specificity war. These problems aren't because CSS is a bad language—they're because there is no architecture. CSS is global by nature, cascading by nature, and source-order-based by nature—put those three "natures" together and, at scale, disaster is guaranteed.

Over the past decade-plus the community has settled on several different paths: BEM avoids conflicts through naming conventions, CSS Modules eliminates conflicts through compile-time scoping, CSS-in-JS gains component scope by pushing styles into JS, and atomic CSS (Tailwind) simply dispenses with the class-name layer. Each approach is a different answer to the same problem. This article lays out these paths clearly and gives a practical judgment on how to choose for a new project.

1. Why CSS Needs Architecture

First, let's confirm the problems we're solving. CSS at scale tends to detonate four kinds of landmines:

  1. Global pollution. Any .btn is a global name; you think it's a button component, but actually the whole site has a dozen-plus .btn, and changing one blows up a swath.
  2. Naming collisions. Names like main, wrapper, container, title are used by everyone; cross-file collisions are the norm.
  3. Specificity wars. Whether #header .nav li a or .nav-link wins depends on selector specificity and source order. In the end everyone competes on whose selector is longer and who has more !importants.
  4. Reuse run amok. The same button's styles get copied five times, and when the design changes you can't clean it up fully.

All four problems share a single root cause: CSS has no concept of component scope. Every architectural pattern is essentially solving one thing—artificially creating a "component scope". The difference is only in how: BEM creates it with naming conventions, CSS Modules with compile-time hashing, CSS-in-JS with JS closures, and atomic CSS simply dispenses with this abstraction layer. Once you understand this, the trade-offs of every following approach become obvious.

2. BEM: Simulating Scope with Naming Conventions

BEM (Block Element Modifier) is the most bare-bones approach: it introduces no tooling and avoids conflicts purely by agreeing on a class-name format. There are just three rules:

  • Block: an independent component entity, such as .card, .nav.
  • Element: an internal part of a Block, connected with double underscores, such as .card__title, .card__image.
  • Modifier: a variant state of a Block or Element, using double hyphens, such as .card--featured, .card__title--large.
html
<article class="card card--featured">
  <img class="card__image" src="..." />
  <h2 class="card__title card__title--large">Title</h2>
  <p class="card__body">Body</p>
</article>
css
.card { padding: 16px; border-radius: 12px; background: #fff; }
.card--featured { border: 2px solid #2563EB; }
.card__title { font-size: 18px; font-weight: 600; }
.card__title--large { font-size: 24px; }

BEM's elegance lies in its flat selectors—.card__title is a single class whose specificity is always (0,1,0) and won't clash with other selectors. It simulates a namespace with a naming prefix (card__): someone else's .title and your .card__title naturally don't conflict. Zero tooling, zero compilation, zero runtime—usable with any framework.

BEM's cost is that it relies on discipline. Once the convention is broken (someone writes .card .title, someone else writes .card-title), the scope is broken. Large teams either enforce it with strict lint rules or watch it closely in code review. The other cost is verbose naming—strings like .card__title--large are genuinely tiring to read.

BEM suits: traditional multi-page apps, legacy jQuery projects, pure static sites that need to serve multiple frameworks (or no framework) simultaneously, and design-system documentation sites. Its biggest advantage is being framework-agnostic—the same set of CSS can be used by React, Vue, and raw HTML at the same time.

3. CSS Modules: Generating Unique Class Names at Compile Time

CSS Modules upgrades "naming convention" to "compile-time guarantee". You write ordinary CSS, and the tooling (webpack css-loader, PostCSS, Vite, etc.) appends a unique hash to each class name at compile time, e.g. .button becomes .button_x3k9f.

css
/* Button.module.css */
.button {
  padding: 8px 16px;
  border-radius: 8px;
}
.primary {
  background: #2563EB;
  color: #fff;
}
tsx
import styles from './Button.module.css';

export function Button({ primary }: { primary?: boolean }) {
  return (
    <button className={`${styles.button} ${primary ? styles.primary : ''}`}>
      Click
    </button>
  );
}

After compilation, styles.button is a globally unique string (e.g. _button_x3k9f_1), and writing another .button in a different file won't interfere. The scope is guaranteed by tooling, not watched by humans—this is the step up it has over BEM.

CSS Modules' advantages are all engineering-side: zero runtime (done at compile time), a one-to-one correspondence with the React component structure (one .tsx paired with one .module.css), and type safety (with a *.d.ts declaration you get autocompletion for styles.button). Its limitation is that it only solves scoping, not the design system—colors, spacing, and variant management still need to be handled yourself, usually paired with CSS variables or a preprocessor (Sass/Less).

css
/* Button.module.css + Sass variables + composition */
.button {
  composes: base from './shared.css';  /* composition reuse */
  background: var(--color-brand);
}

CSS Modules suits: React/Vue componentized projects, those who want styles and components to physically coexist (one component, one style file), and those sensitive to runtime performance (the first screen can't afford CSS-in-JS hydration cost). Next.js supports .module.css by default, out of the box.

4. CSS-in-JS: Pushing Styles into JS for True Component Scope

The core insight of CSS-in-JS is: since JS has scope, if we write CSS inside JS, don't we get real scope? It treats styles as part of the component rather than a separate resource. Taking styled-components as an example:

tsx
import styled from 'styled-components';

const Button = styled.button<{ primary?: boolean }>`
  padding: 8px 16px;
  border-radius: 8px;
  background: ${props => props.primary ? '#2563EB' : 'transparent'};
  color: ${props => props.primary ? '#fff' : '#2563EB'};
`;

// Used just like an ordinary component
<Button primary>Click</Button>

This pulls CSS entirely into the component's closure. The styles in the styled.button template string belong only to this Button; at runtime a unique class name is generated and injected into <head>. Its killer feature is true dynamism—props.primary goes straight into the styles, making theme switching, user preferences, and runtime state all natural.

emotion takes a similar path, with a more flexible API and slightly better performance. Both were originally runtime solutions: styles are computed, generated, and injected in real time in the browser. This brings three pain points:

  1. Runtime cost: bundle size, style computation, and <head> injection all have overhead, so first-screen hydration is a half-beat slower.
  2. Complex SSR: server-side rendering has to extract styles and string them into the HTML, requiring extra configuration for framework integration.
  3. Hard-to-read DevTools: in the browser you see hashed class names like .sc-1x2y3z, and debugging requires opening React DevTools.

The trend in recent years is zero-runtime CSS-in-JS, represented by linaria, vanilla-extract, panda-css, and compiled. They extract styles into ordinary .css files at compile time, with zero runtime overhead, while preserving the dev experience of "writing styles inside components and referencing JS variables":

ts
// vanilla-extract: compile-time evaluation, zero runtime cost
import { style } from '@vanilla-extract/css';

export const button = style({
  padding: '8px 16px',
  background: vars.color.brand,  // reference design token
});

CSS-in-JS suits:

  • Strongly dynamic themes: user-customizable themes (white-label products, visual editors), frequently changing runtime colors—the runtime approach is irreplaceable.
  • Highly componentized design systems: styles, logic, and types bound tightly within one component, with the strongest portability.
  • New projects that accept zero-runtime: vanilla-extract / panda-css is the direction most worth considering today, enjoying "the CSS-in-JS dev experience" without the performance baggage.

Warning: runtime CSS-in-JS (styled-components/emotion) has a real first-screen cost in SSR-heavy, performance-sensitive projects. Don't reach for it reflexively on a new project—first think about how dynamic things really need to be.

5. Atomic CSS (Tailwind/Windi): No Class-Name Layer

Atomic CSS goes to the other extreme: simply dispense with semantic class names and write utilities directly. The long string flex items-center gap-4 p-4 rounded-lg bg-blue-600 is the style itself.

tsx
<button className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
  Click
</button>

It is a completely different paradigm from the previous patterns:

  • BEM / Modules / CSS-in-JS are all "first abstract semantic classes, then write styles"—essentially an abstraction layer.
  • Atomic CSS is "no abstraction layer, directly assemble constrained atoms", pushing the abstraction layer onto design tokens (colors, spacing, font sizes in the config).

Its dividends: JIT on-demand generation, a tiny bundle, no need to think up class names, and design tokens converge in the config. The cost: classNames are very long, and abstraction has to be done yourself via React components or cva (we covered this in detail in "Tailwind CSS Engineering Practices", so we won't expand on it here).

Atomic CSS suits: componentized projects like React/Vue, those who want the smallest possible bundle, and design systems that can converge into tokens. Not suitable for: framework-less pure static sites (no component abstraction layer to absorb the class pile-up) and strongly dynamic themes (atomic classes are weak at runtime theme switching).

6. New Native CSS: Browsers Are Absorbing the Strengths of These Patterns

Over the past few years the CSS standard has been rapidly absorbing the ideas behind the patterns above. Three are worth particular attention:

Native Nesting—Sass's nesting has entered the standard, no preprocessor needed:

css
.card {
  padding: 16px;
  &__title { font-size: 18px; }
  &:hover { background: #f5f5f5; }
  &.featured { border: 2px solid #2563EB; }
}

Cascade Layers (@layer)—you can finally explicitly control the cascade order, no longer relying on selector specificity and source order:

css
@layer reset, framework, components, utilities;

@layer reset {
  * { box-sizing: border-box; margin: 0; }
}
@layer framework {
  /* third-party library styles */
}
@layer components {
  .button { /* lower priority than utilities, higher than framework */ }
}
@layer utilities {
  .text-center { text-align: center; }  /* always wins, predictable */
}

This is the first time CSS has given developers the ability to declaratively manage priority. The utilities layer holds atomic classes, the components layer holds component classes, and the framework's styles are pinned to the bottom—who wins is obvious at a glance—no more !important.

@scope—native scope, truly giving CSS a "block scope" in the real sense:

css
@scope (.card) {
  :scope { padding: 16px; }
  .title { font-size: 18px; }  /* only takes effect inside .card */
}

This approaches the semantics of CSS Modules but is supported natively by the browser, with no compilation needed.

These standards let CSS itself begin to acquire "architecture" capabilities. In the short term they won't replace existing approaches (browser compatibility, toolchain support, and team habits all need to catch up), but the direction is clear: the patterns the community figured out over a decade are settling back into the CSS standard. New projects can start using @layer to manage the cascade (the downgrade cost is extremely low); @scope and native nesting are worth trying in progressive-enhancement scenarios.

7. Selection: Don't Ask Which Is Best, Ask About Your Constraints

Finally, a practical judgment. There is no "best" CSS solution, only the one that "best matches your constraints".

  • New project, single React/Vue framework, design system converges into tokens → Tailwind or CSS Modules. Tailwind has the smallest bundle and fastest development; CSS Modules is closer to the traditional mental model and has zero SSR burden. Both are simpler than CSS-in-JS.
  • Strongly dynamic themes (white-label, visual editors, frequent runtime color switching) → runtime CSS-in-JS (styled-components/emotion). In this scenario its dynamic capability is irreplaceable.
  • New project but wanting the CSS-in-JS dev experience with no runtime cost → vanilla-extract or panda-css. This is the direction most worth betting on after 2024.
  • Traditional multi-page, legacy jQuery projects, or the same CSS reused across frameworks → BEM. Framework-agnostic is its irreplaceable advantage, and the migration cost is zero.
  • Large design systems / component libraries → atomic CSS + cva, or CSS Modules + CSS variables. The point is token convergence and variant management; the approach is just the means.

A few counter-intuitive but important judgments:

  1. Don't mix multiple paradigms in one project. A project running BEM + CSS Modules + styled-components all at once has triple the maintenance cost of a single approach. Pick one and carry it through to the end.
  2. New projects should not default to runtime CSS-in-JS. Unless you have a strongly dynamic theme requirement, Tailwind/CSS Modules/zero-runtime solutions are all better. The heyday of runtime CSS-in-JS has passed.
  3. BEM isn't outdated; its "outdatedness" is overrated. In framework-less or cross-framework scenarios it remains the safest choice. Treat it as "the lowest-cost scoping solution".

Summary

  • The essence of CSS architecture is creating component scope: BEM uses naming conventions, Modules uses compile-time hashing, CSS-in-JS uses JS closures, and atomic CSS simply dispenses with the class-name layer—four paths solving the same problem.
  • BEM is framework-agnostic with zero tooling cost: suitable for traditional projects, multi-framework reuse, and pure static sites. The cost is that it relies on discipline.
  • CSS Modules is the most stable default for React projects: compile-time scope, zero runtime, no SSR burden, with the lowest mental cost.
  • CSS-in-JS depends on the scenario: runtime solutions (styled-components/emotion) suit strongly dynamic themes, while zero-runtime (vanilla-extract/panda-css) is the first choice for new projects.
  • Atomic CSS takes another path: no abstraction layer, with design tokens as the foundation, but it must be paired with component abstraction to avoid losing control.
  • The CSS standard is absorbing these patterns: @layer / @scope / native nesting give CSS built-in architecture capabilities—worth following, but they won't replace existing approaches in the short term.

Key takeaway: There is no best CSS architecture, only the one that best matches your constraints—pick one and carry it through to the end; that beats ten years of hopping back and forth.

Related

Related Content