Skip to content

CSS Architecture: Global → Modules → Utility-First

Migration criteria between global CSS, CSS Modules, and utility-first, a decision table for teams that need coherence without endless debate.

5 min read
CSS
CSS Modules
Tailwind
Architecture
Frontend
Styling

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

ForceGlobal leansModules leanUtility-first leans
Team CSS skillStrong cascade literacyPrefer colocated ownershipPrefer product engineers shipping UI without naming classes
Design system maturityThin, few tokensComponents own appearanceTokens + components; utilities for the last mile
Dead code riskHigh without disciplineLower (unused modules detectable)Unused class strings harder; purge/scan helps
Specificity warsFrequentRare locally; watch :globalRare, fight is repeated class soup
Cross-package UI libraryPossible, carefulNaturalNatural if preset/tokens shared
Marketing / content pagesFineFineOften fastest for layout iteration
Highly custom illustration UIFineFineUtilities 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 .button and 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 className string 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:

  1. Tokens are global (variables or Tailwind theme), one source of truth.
  2. Components own reusable UI: do not restyle raw <button> fifty ways.
  3. Utilities are for layout and one-offs inside those components or pages.
  4. 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.