CSS Architecture with Tailwind at Scale: Many Teams, One Codebase
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Tailwind solves the problem of writing CSS fast for one person very well. Many people contributing to one repo is a different story. The interface starts to drift. Everyone picks a slightly different shade of gray, a slightly off padding value. A few months later the UI is no longer consistent, even though each line, read on its own, is “valid Tailwind”.
Valid syntax, broken system
Tailwind doesn’t force you to use any particular token. text-gray-500, text-slate-500, text-neutral-500: all three are syntactically valid. But when three people need the same “secondary, low-emphasis text” and each picks a different value, you end up with three nearly identical grays scattered across the app. Nobody did it on purpose.
Spacing is the same. p-4 or p-5, gap-3 or gap-4. Off by one step, nobody notices. Repeated across hundreds of components, the UI looks uneven.
Three people, three valid choices, one result:
flowchart LR A["Dev A needs 'secondary text'"] --> G["text-gray-500"] B["Dev B needs 'secondary text'"] --> S["text-slate-500"] C["Dev C needs 'secondary text'"] --> N["text-neutral-500"] G --> UI["UI is no longer consistent"] S --> UI N --> UI
Semantic tokens instead of the raw palette
Instead of letting everyone pick from Tailwind’s full color palette, define a set of semantic tokens, named by meaning rather than by color, in tailwind.config:
// tailwind.config.js
export default {
theme: {
extend: {
colors: {
surface: {
DEFAULT: "hsl(var(--surface))",
subtle: "hsl(var(--surface-subtle))",
},
content: {
DEFAULT: "hsl(var(--content))",
muted: "hsl(var(--content-muted))",
},
border: {
DEFAULT: "hsl(var(--border))",
},
},
},
},
};
Developers only need to remember one concept: text-content-muted. No more wondering whether secondary text is neutral-500 or slate-500. One meaning, one token. No room left to drift.
The way tokens break: arbitrary values
Tailwind lets you write arbitrary values like mt-[13px] or text-[#3b82f6]. Flexible, sure, but it’s also the way your system tokens break. text-[#3a82f6] is off by exactly one hex character from the standard color. A human reviewer will hardly catch it. And so a color that’s close but not the real one slips into the codebase.
The convention we settled on: arbitrary values are only for things that can’t be expressed with a system token, such as a one-off grid-template-columns. Never for color or spacing. A verbal convention doesn’t last, so we let eslint-plugin-tailwindcss block certain arbitrary-value patterns in CI instead of counting on reviewers’ eyes.
@apply: use it with control
The age-old argument: utility classes directly in JSX, or grouped with @apply in a separate CSS file? We chose both, each in its own place.
- A component used once, with simple logic → utility classes directly, no extra abstraction.
- A pattern repeated in many places (say, the style for every
<Card>, every validation-error input) → group it into one semantic class with@apply, rather than repeating the same long string of utilities dozens of times:
/* components.css */
.card-surface {
@apply rounded-xl border border-border bg-surface p-4 shadow-sm;
}
.input-error {
@apply border-red-500 focus:ring-red-500/30 text-red-600;
}
But don’t abuse @apply to rebuild a whole traditional CSS system, one class per component. Do that and you lose the benefit of utility-first. Only group patterns that repeat a lot and are stable, rarely needing changes.
One source of truth for the spacing scale
Every PR decides for itself whether to use gap-3 or gap-4 in the same context, like the spacing between fields in a form. So we wrote a short Spacing Guide page in Storybook: spacing between fields is gap-4, between sections is gap-8, card padding is p-6. No hard rule in the compiler. Just a place to look things up quickly, and it still cuts out those review rounds of “the spacing doesn’t match elsewhere”.
After the change
After semantic tokens, limits on arbitrary values and controlled @apply, review comments like “the color here is slightly different from there” dropped sharply. Changing the theme for a campaign means editing the CSS variables in one place, instead of find-and-replacing hundreds of blue-500 uses scattered across the repo.
The spacing scale is still just documentation, not a fence. If someone strays, the reviewer catches it. For us that’s enough.
Keep reading
- Deep Dive into a ReAct Runtime in Go: Building an MCP-Compatible AI AgentEngineering Deep Dive
- Multi-Provider Payment Gateway Architecture: Handling Stripe, Adyen and Asian Regional Payment ProvidersEngineering Deep Dive
- TradingView Advanced Charts + Web Workers: From Tick to Candle, Frontend and BackendEngineering Deep Dive
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.