Why I Still Care About CSS Architecture in a Utility-Class World
Tailwind didn't solve CSS architecture — it deferred it. The decisions still exist; they just live in component files now instead of stylesheets.
Tailwind moved the complexity of CSS into JSX. That is a real tradeoff, not a solution. The decisions about encapsulation, naming, token application, and style composition did not go away — they just changed shape. If you are not thinking about CSS architecture in a Tailwind project, you are accumulating debt in your component files instead of your stylesheets.
Architecture vs Methodology
These are different things and the confusion between them produces muddled arguments about framework choice.
CSS methodology is a set of conventions for writing CSS: BEM gives you naming rules, SMACSS gives you file organization categories, OOCSS gives you structural vs skin separation. Utility-first is a methodology.
CSS architecture is how styles are scoped, composed, and connected to design decisions in your codebase. It answers: where does a style live, who owns it, how does it get shared, and how do design tokens flow into rendered markup?
You can have good architecture with any methodology. You can have bad architecture with any methodology, including Tailwind. The teams that adopted Tailwind hoping it would solve their CSS chaos found that it moved the chaos — it did not eliminate it.
What Tailwind Actually Changes
Tailwind eliminates the authoring of new CSS for most components. That is genuinely useful. You do not write .product-card-title { font-size: 1.125rem; font-weight: 600; } — you write text-lg font-semibold. The generated CSS is a fixed utility set shared across the entire project, so the stylesheet grows slowly as the application grows.
What Tailwind does not change:
Encapsulation boundaries. In CSS Modules, the .module.css file is the encapsulation unit. Styles in it cannot leak unless you explicitly use :global. In Tailwind, a component's styles are the string of classes in its JSX — and there is nothing preventing one component from rendering classes that visually interfere with another.
Specificity and cascade decisions. Tailwind utilities have uniform specificity, which is an improvement. But once you add any custom CSS — and you always eventually add custom CSS — you are back to managing the cascade.
Token application decisions. The decision about which color to use where does not live in Tailwind's utility set. bg-violet-600 and bg-violet-700 are both available; nothing in the framework tells you which one is "brand" and when to use it. That decision is CSS architecture, not CSS methodology.
What CSS Modules Get Right
CSS Modules solve the encapsulation problem correctly. Each module is a local scope by default. The class name .button in Button.module.css compiles to .Button_button_x7k3a at build time — it cannot collide with .button anywhere else in the codebase.
/* Button.module.css */
.root {
display: inline-flex;
align-items: center;
padding: var(--space-2) var(--space-4);
background: var(--color-brand);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: 600;
color: white;
transition: background 150ms ease;
}
.root:hover {
background: var(--color-brand-hover);
}
.root:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.root[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
/* Size variants as composable classes */
.sm { padding: var(--space-1) var(--space-3); font-size: var(--font-size-xs); }
.lg { padding: var(--space-3) var(--space-6); font-size: var(--font-size-base); }
// Button.tsx
import styles from "./Button.module.css";
import clsx from "clsx";
export function Button({ size = "md", className, ...props }) {
return (
<button
className={clsx(styles.root, size === "sm" && styles.sm, size === "lg" && styles.lg, className)}
{...props}
/>
);
}
Notice what is happening with the CSS custom properties here. The style decisions (--color-brand, --space-4, --radius-md) come from a token layer. The encapsulation and composition come from CSS Modules. These are two separate concerns working together.
This is the pattern used throughout this site. The .module.css file is the style contract for the component. The design tokens are the shared language across all contracts.
When CSS-in-JS Made Sense
CSS-in-JS libraries — styled-components, Emotion, Stitches — solved real problems at the time they appeared. In 2017–2020, React apps were growing fast, CSS Modules support in various bundlers was inconsistent, and co-locating styles with components in a single file felt like a genuine ergonomic win.
The runtime overhead was acceptable when server rendering was less dominant. JavaScript-driven style injection was a workable tradeoff when the bundle size implications were not yet well understood.
Both of those conditions changed. Server Components in Next.js App Router do not support CSS-in-JS that requires runtime context. The performance case against large JavaScript bundles became harder to ignore. The ecosystem moved toward either zero-runtime approaches (Vanilla Extract, Panda CSS) or back to CSS Modules and utility classes.
The lesson is not that CSS-in-JS was wrong. It is that tool choices should track actual constraints, and the constraints changed.
The Token Application Problem
This is where Tailwind projects accumulate the most hidden debt.
Design tokens exist at two levels: raw values (purple-600 = #7c3aed) and semantic mappings (brand = purple-600, brand-hover = purple-700). The semantic layer is what makes a design system refactorable — when the brand color changes, you update one mapping, not every component.
In a CSS custom properties system, this mapping is explicit:
:root {
/* Raw scale */
--purple-600: #7c3aed;
--purple-700: #6d28d9;
/* Semantic mapping */
--color-brand: var(--purple-600);
--color-brand-hover: var(--purple-700);
}
Components reference --color-brand, never --purple-600. When the design team changes brand from purple to teal, one line changes.
In Tailwind, without additional tooling, this semantic layer does not exist in a useful form. bg-violet-600 is the raw value. The mapping — "this is the brand color" — lives nowhere expressible. You can extend the Tailwind config:
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
brand: {
DEFAULT: "#7c3aed",
hover: "#6d28d9",
},
},
},
},
};
Now bg-brand and bg-brand-hover exist. This is closer to correct. But the config is static — it does not support runtime theming, it requires a rebuild to change values, and the semantic meaning is only as good as whoever writes the config names.
CSS custom properties with a semantic token layer give you runtime theming, design-tool-to-code synchronization (Style Dictionary, Token Pipeline), and a refactoring surface that exists at build time and runtime. Tailwind's config gives you compile-time utility generation. Both are valid, but they are not equivalent.
The Actual Tradeoffs
This is not a case for or against Tailwind. It is a case for being honest about what problem you are solving.
Use Tailwind when: you are moving fast on a product without a dedicated design system, you want a consistent utility vocabulary across the team, and your theming needs are limited to compile-time configuration.
Use CSS Modules when: you are building a component library where encapsulation is the primary concern, your design token layer needs to be runtime-swappable, or you want your style contract to be legible in a single file separate from component logic.
Use both when: your application-level pages use Tailwind for layout and one-off styles, your shared component library uses CSS Modules for encapsulation and token application. This is a coherent split and it reflects the actual ownership boundary in most codebases.
The architecture question is: who owns this style, how does it get shared, and how do design decisions flow into it? Tailwind does not answer those questions. CSS Modules answers the scoping question. Token systems answer the design-to-code question. You still have to think about all three.