CSSPublished 1/25/2024

CSS Architecture Strategies: From BEM to Tailwind CSS and UnoCSS

A comparison of how BEM, CSS Modules, Tailwind CSS, and UnoCSS each tackle naming collisions and scoping, to help you figure out which strategy fits a large project.

Why does CSS spiral out of control on big projects?

If you've maintained a project that's been alive for a few years, you've probably run into this: you change the color of one button, and suddenly a button on a completely different page changes too, because the two buttons accidentally shared the same class name. Or the opposite happens — you want to reuse a chunk of styling, but you don't dare apply the existing class directly, because you have no idea what else it might break. So you copy-paste it and give it a new name instead.

The root of the problem is that CSS is global by design: any selector you write can, in principle, match every matching element on the page — there are no boundaries. That's not an issue on a small project; you can just remember what every class name means. But once the project and the team both grow, "has someone already used this class name?" turns into a gamble that depends entirely on memory and tribal knowledge. BEM, CSS Modules, Tailwind CSS, and UnoCSS are all, in one way or another, answering the same question: how do you give CSS the scoping boundaries it was never born with?

BEM: drawing boundaries through naming convention

BEM stands for Block, Element, Modifier. It doesn't change how CSS actually works — it's purely a naming convention. The class name itself spells out "whose part this is, and what state it's in," so you can tell just by reading the name whether it's safe to change something without breaking somewhere else.

css
/* Block: a standalone component unit */
.card {
}
 
/* Element: a part that belongs to this block, joined with a double underscore */
.card__title {
}
 
/* Modifier: a variant state of this block or element, joined with a double hyphen */
.card--highlighted {
}
html
<div class="card card--highlighted">
  <h2 class="card__title">Title</h2>
</div>

See .card__title, and you know it's a part belonging to .card — before touching it, you'd check whether .card is used anywhere else. See .card--highlighted, and you know it's just a state variant that won't touch .card's base styles. None of this needs a build tool; it works with hand-written CSS alone, which is exactly why BEM is still alive in plenty of legacy projects and environments where a build step isn't an option.

But BEM's boundaries only exist as long as everyone follows the convention. There's no actual technical enforcement of scope isolation — .card__title is still, at the end of the day, a global class. If someone accidentally writes the exact same name, or ignores the convention entirely, BEM has no way to stop it. It solves a communication problem between people, not a runtime problem in the browser.

CSS Modules: let the build tool actually isolate scope

CSS Modules takes a different angle: instead of relying on discipline, let the build tool rewrite every class name into something unique at bundle time, making naming collisions physically impossible from the ground up.

css
/* Card.module.css */
.title {
  font-size: 1.5rem;
}
jsx
import styles from './Card.module.css'
 
function Card() {
  return <h2 className={styles.title}>Title</h2>
}

What you write is a perfectly ordinary .title, but the bundler rewrites it into something like Card_title__a3f9k — a unique string that carries information about which file it came from — so the class name that actually ships to the browser is guaranteed not to collide with anything. That means you can finally use the most obvious, intuitive names without prefixing or padding them out just to dodge conflicts.

The tradeoff is that this only works with a build tool (webpack, Vite, and the like) — no bundling step, no CSS Modules. It's also still built around a "one component, one CSS file" mindset: styles stay scattered across individual .module.css files, and sharing a chunk of styling across components usually means reaching for the composes syntax or pulling out a separate shared class — not the free-for-all cross-file reuse you get with a BEM class.

Tailwind CSS: just don't have shared class names at all

Tailwind CSS takes a completely different path, known as utility-first: instead of encouraging you to name things and write your own CSS rules, it hands you a huge set of single-purpose atomic classes (utility classes) that you compose directly in your HTML.

html
<div class="rounded-lg bg-blue-500 p-4 text-white shadow-md">
  <h2 class="text-xl font-bold">Title</h2>
</div>

rounded-lg only handles corner radius, bg-blue-500 only handles background color, p-4 only handles padding — each class has one job, narrow enough that there's no semantic overlap, because they describe visual properties, not a component's role. Naming collisions barely exist in this model, because you never have to name anything yourself.

The tradeoffs are just as obvious: your markup ends up crowded with classes, and that's often the first thing people find hard to read. Consistency no longer comes from rules written in a CSS file — it comes from the whole team pulling from the same Tailwind config (colors, spacing, font sizes, all the design tokens live there). As long as everyone pulls values from that same config, visual consistency follows naturally. As for bundle size, Tailwind scans your source code and keeps only the classes actually used (the mechanism currently called the Just-In-Time engine), so the CSS that actually ships is usually much smaller than people expect.

UnoCSS: taking Tailwind's idea and making it more instant, more flexible

UnoCSS runs with the same utility-first idea, and its syntax deliberately mirrors Tailwind CSS closely enough that anyone used to Tailwind barely has to relearn anything. The biggest difference is under the hood: UnoCSS bills itself as an instant on-demand atomic CSS engine — it generates zero classes upfront, and only produces the small CSS rule for a given class the moment it sees that class actually used somewhere in your source code during the build.

html
<!-- syntax is nearly identical to Tailwind -->
<div class="flex items-center gap-2 rounded-lg bg-blue-500 p-4">
  <span class="text-white">Content</span>
</div>

Because generation is on-demand, UnoCSS never needs the kind of full-source-scan-then-purge step Tailwind relies on, so build times during development are usually noticeably faster. It also exposes rules and presets: you can define an entirely custom set of atomic class rules, or just drop in the official preset that's compatible with Tailwind syntax — and you can stack additional presets on top (like attributify mode, which lets you write classes as HTML attributes instead, closer to a CSS-in-JS feel). In short, what UnoCSS actually gives you is an engine and a rule system; Tailwind compatibility is just the most common shape that takes. It's more flexible than Tailwind itself, but that also means investing more effort into understanding its rule mechanics instead of just dropping in an off-the-shelf config.

A quick comparison table

StrategyHow it handles naming collisionsNeeds a build tool?Markup intrusivenessWhere consistency comes from
BEMNaming convention, enforced by peopleNoLow — semantic class namesTeam discipline and code review
CSS ModulesBuild tool rewrites to unique namesYesLow — styles stay in CSS filesShared components, shared style utils
Tailwind CSSAtomic classes, no naming neededYes (PostCSS)High — classes pile up in markupShared config (design tokens)
UnoCSSSame as Tailwind, generated on demandYesHigh — classes pile up in markupShared config and presets

So which one should a big project actually pick?

There's no universally correct answer — the choice usually comes down to a few practical factors:

  • How comfortable the team is with utility-first: Tailwind CSS and UnoCSS require the team to accept a real shift in reading habits — styling living in the markup. Rolling it out wholesale before the team has bought in tends to trigger pushback.
  • Whether a build tool is already in place: CSS Modules, Tailwind CSS, and UnoCSS all depend on a build pipeline. If the project's environment can't support a bundler (some legacy systems that output static HTML directly, for instance), BEM is pretty much the only option left.
  • How much bundle size and build speed matter: build time itself is a real cost on large projects, and UnoCSS's on-demand generation usually has an edge here over traditional Tailwind.
  • Whether you need a highly customized design system: if the project runs on a rigorous set of design tokens and needs deep customization of its atomic classes, UnoCSS's open rules mechanism gives you more room to maneuver than just applying Tailwind's defaults.

In practice, these strategies aren't mutually exclusive, and mixing them is common: use Tailwind CSS or UnoCSS for layout and spacing — the highly repetitive stuff — and fall back to a dedicated CSS Modules file for animations, complex pseudo-elements, or logic that's awkward to express with atomic classes. The two complement each other; there's no need to force an entire project into a single approach.

A common trap: thinking a new tool means you don't need conventions anymore

Whichever strategy you pick, none of them let you stop thinking about architecture entirely. BEM only works if the team actually follows the convention — otherwise the boundaries are purely nominal. CSS Modules solves scoping, but if every component's style file is written in isolation with no shared spacing or color system, the visuals will still drift apart over time. Tailwind CSS and UnoCSS don't require naming, but if the design tokens in the config aren't properly planned and engineers start dropping in arbitrary values wherever they like (like mt-[13px]), you end up right back at the "everything looks slightly different" problem you were trying to avoid in the first place. Tools solve the technical side of isolation; the team still has to agree on what "consistent" actually means.

Summary

BEM, CSS Modules, Tailwind CSS, and UnoCSS are really four different answers to the same question. BEM draws boundaries in people's heads through naming convention. CSS Modules hands scope isolation off to the build tool at compile time. Tailwind CSS sidesteps naming altogether. UnoCSS takes that same idea and makes it more instant and more flexible. Which one fits a large project depends on team habits, the existing build setup, and how much customization you need — not on which one happens to be newest. What actually determines whether styles stay maintainable long-term is whether the team shares a real understanding of what consistency means. The tooling is just there to help enforce it more thoroughly.