HMR makes style edits feel free. Architecture decides whether those edits stay
cheap six months later. I have watched teams swing from a single global.css to
CSS Modules to utility-first (Tailwind and friends), sometimes for taste, often
for pain they never named.
What follows is the table I actually use before the next migration.
Tailwind v4 shipped January 2025; v3 is still everywhere. The architecture arguments below are version-agnostic. I am not selling a framework.
Three models (honest definitions)
Global CSS: selectors in shared stylesheets. Cascade and specificity are the API. Easy to start; collisions and dead CSS are the tax.
CSS Modules: each file generates scoped class names. Composition via
composes or importing classes into JS. Local by default; global escapes are
explicit.
Utility-first: design tokens become tiny classes applied in markup (flex,
gap-2, text-sm). Shared UI still exists, as components, but most one-off
styling does not invent a class name.
css/* global, everyone can collide */ .button { padding: 0.5rem 1rem; }
css/* Button.module.css, scoped */ .root { padding: 0.5rem 1rem; }
tsx// utility-first, styles at the call site export function Button({ children }: { children: React.ReactNode }) { return ( <button type="button" className="inline-flex items-center rounded-md px-4 py-2 text-sm font-medium" > {children} </button> ); }
Decision table
| Force | Global leans | Modules lean | Utility-first leans |
|---|---|---|---|
| Team CSS skill | Strong cascade literacy | Prefer colocated ownership | Prefer product engineers shipping UI without naming classes |
| Design system maturity | Thin, few tokens | Components own appearance | Tokens + components; utilities for the last mile |
| Dead code risk | High without discipline | Lower (unused modules detectable) | Unused class strings harder; purge/scan helps |
| Specificity wars | Frequent | Rare locally; watch :global | Rare, fight is repeated class soup |
| Cross-package UI library | Possible, careful | Natural | Natural if preset/tokens shared |
| Marketing / content pages | Fine | Fine | Often fastest for layout iteration |
| Highly custom illustration UI | Fine | Fine | Utilities get verbose; Modules or plain CSS may win |
Score the product + team, not Twitter.
Migration criteria (when to move)
Global → Modules
Move when:
- Two features ship
.buttonand break each other - You cannot delete CSS safely
- New hires ask "where do I put this?" and get three answers
Do not move when the app is a small marketing site with one stylesheet and no collisions. Scoped CSS modules add build wiring you do not need yet.
Modules → Utility-first
Move when:
- Half your modules are one-off spacing/color wrappers
- Design tokens already exist (or you are ready to define them)
- Review cycles bog down on inventing class names for layout
Do not move when you lack a component layer, utilities without buttons, inputs, and tokens become copy-paste HTML with worse diffs.
Utility-first → Modules (or hybrid)
Move pieces back when:
- A component's
classNamestring is a paragraph - You need complex selectors / animations that utilities fight
- A package must ship without forcing a Tailwind pipeline on consumers
Hybrids are normal: utilities for layout, modules for dense widgets, a tiny global for resets and CSS variables.
A hybrid that stays coherent
css/* styles/tokens.css, global variables, not global components */ :root { --color-fg: #0a0a0a; --color-bg: #ffffff; --space-2: 0.5rem; --radius-md: 0.375rem; }
tsx// components/Card.tsx, utilities consume tokens via theme; complex bits module import styles from './Card.module.css'; export function Card({ title, children, }: { title: string; children: React.ReactNode; }) { return ( <section className={`rounded-[var(--radius-md)] p-4 shadow-sm ${styles.root}`} > <h2 className="text-lg font-semibold text-[var(--color-fg)]">{title}</h2> <div className={styles.body}>{children}</div> </section> ); }
Rules of the hybrid:
- Tokens are global (variables or Tailwind theme), one source of truth.
- Components own reusable UI: do not restyle raw
<button>fifty ways. - Utilities are for layout and one-offs inside those components or pages.
- Modules for complexity: selectors, animations, dense partials.
Failure modes
Rewriting everything in one PR. Migrate on touch; set a north star in an ADR. Big-bang CSS rewrites burn quarters.
Utilities without tokens. Magic hex codes in class strings recreate global chaos with longer lines.
Modules plus deep :global soup. You kept the worst of both.
Banishing CSS knowledge. Utility-first still needs someone who understands cascade, stacking, and accessibility (focus rings, contrast, motion).
Treating Tailwind v4 migration as architecture. Engine changes are real; "should we use utilities" is a separate decision. Do not conflate them.
Continuity
This closes a stretch from Astro islands through framework choice, lint cost, env honesty, Sentry, and HMR. Design tokens and dark mode follow, the natural sequel once you have an architecture that can host tokens without lying.
Takeaway
CSS architecture is a bet on where class names live and who owns tokens. Move global → modules when collisions and dead CSS hurt; move to utilities when tokens and components exist and naming one-offs is the bottleneck, hybrid on purpose, endless debate never.