Every React project hits the styling question eventually. It usually happens right after you create the app. You stare at the blank component file and think, "Okay, how am I actually going to style this thing?"
The JavaScript ecosystem gives you about a dozen answers. Styled components. CSS-in-JS. Sass. Plain CSS files. Utility-first frameworks. CSS Modules. Each one has passionate advocates and equally passionate detractors.
But for the past few years, two approaches have pulled ahead of the pack for new projects: CSS Modules and Tailwind CSS. They represent fundamentally different philosophies about how styles should relate to components. And honestly? Both are good choices. The question is which one fits your brain and your team.
Let me walk through the comparison honestly — no "X is garbage" hot takes, no pretending one approach solves everything. Just the tradeoffs as I've experienced them across real projects.
CSS Modules and Tailwind come from different beliefs about what makes CSS hard to manage at scale.
CSS Modules believes the problem is naming things and scoping styles. Global CSS is the enemy. When you write .button in a stylesheet, you don't know what other .button classes might be lurking in other files. CSS Modules solves this by automatically scoping class names to the component that imports them. You write .button in your CSS file, and at build time it becomes something like .button_header_abc123 — guaranteed unique across your entire application.
Tailwind believes the problem is context switching and specificity wars. Jumping between JSX files and CSS files creates friction. Naming classes is a cognitive tax that doesn't add value. Tailwind solves this by putting all the styling inline, using predefined utility classes that map directly to CSS properties. You never leave your JSX. You never name a class. You never wonder which stylesheet a particular style came from.
Neither belief is wrong. They're solving different aspects of the same problem. The question is which aspect frustrates you more.
CSS Modules are co-located with components. A Button.tsx file typically has a Button.module.css file right next to it. You import the styles as a JavaScript object and reference the generated class names in your JSX.
/* Button.module.css */
.button {
display: inline-flex;
align-items: center;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease;
}
.primary {
background-color: #3b82f6;
color: white;
}
.primary:hover {
background-color: #2563eb;
}
.secondary {
background-color: transparent;
color: #3b82f6;
border: 1px solid #3b82f6;
}
// Button.tsx
import styles from "./Button.module.css";
function Button({ variant = "primary", children }) {
return (
<button className={`${styles.button} ${styles[variant]}`}>
{children}
</button>
);
}
The DX here is genuinely pleasant. You write real CSS. Not a subset. Not a preprocessor. Plain, standard CSS with all the features modern browsers support — custom properties, container queries, cascade layers, nesting. There's no abstraction between you and the styling language.
The scoping is automatic and invisible. You never think about naming collisions because they can't happen. You can use .button in every component without a second thought.
The downside is that you're still writing CSS. For simple components, the CSS file might be longer than the component file. You're still naming things, still managing a separate file, still context-switching between markup and styles. If that friction bothers you, Tailwind's approach will feel like a relief.
Tailwind puts everything in your JSX. No separate CSS file. No class naming. Just utility classes that correspond to CSS properties.
function Button({ variant = "primary", children }) {
const baseClasses =
"inline-flex items-center px-6 py-3 rounded-lg font-semibold cursor-pointer transition-colors duration-200";
const variantClasses =
variant === "primary"
? "bg-blue-500 text-white hover:bg-blue-600"
: "bg-transparent text-blue-500 border border-blue-500";
return (
<button className={`${baseClasses} ${variantClasses}`}>{children}</button>
);
}
The first time you see a component like this, it looks wrong. Classes stretching across the screen. Visual noise everywhere. Every CSS property spelled out as a class name.
Then you use it for a week, and something shifts. You realize you haven't opened a CSS file. You haven't named anything. You made a design change without scrolling to a different part of the file, much less a different file entirely. The context switching that used to eat up cognitive energy just isn't there.
Tailwind's constraints are also its strength. You have a design system built into the framework — a spacing scale, a color palette, breakpoint values. You can customize them, but the defaults push you toward consistency. Two different developers building two different components will naturally arrive at similar spacing and sizing because they're both choosing from the same scale.
The tradeoff is verbosity. Complex components with many conditional styles can become hard to read. The JSX gets buried under a wall of utility classes. Extracting components and using template literals helps, but the visual noise is real.
Let's talk about what actually ships to users. Both approaches are good on performance, but they're good in different ways.
CSS Modules produce actual CSS files. At build time, the CSS is extracted, scoped, and output as static files. The browser loads them like any other stylesheet. There's no runtime overhead. No JavaScript execution for styling. The bundle size is proportional to how much CSS you actually write.
Tailwind in its raw form produces a massive CSS file — thousands of utility classes, most of which you won't use. This is where PurgeCSS or Tailwind's built-in JIT engine comes in. At build time, Tailwind scans your source files and removes any classes that aren't referenced. The output is a CSS file containing only the utilities you actually used.
The result is surprisingly small. A typical Tailwind output file is 5-10 KB gzipped. The JIT compiler generates styles on demand during development, and the production build strips everything unused.
Both approaches deliver lean CSS to the browser. Neither has meaningful runtime JavaScript overhead. The performance difference between them is negligible for virtually all applications. If you've been working through our performance topics like preventing unnecessary re-renders, you know that JavaScript execution and render cycles dominate frontend performance. CSS delivery is rarely the bottleneck.
This is where the conversation gets interesting. Both approaches claim to solve CSS maintainability. They just have different ideas about what maintainability means.
CSS Modules shine when you have complex, component-specific styles. A data table with intricate hover states, responsive breakpoints, and variant-driven styling feels natural in a CSS Module. The styles live next to the component. The file is self-contained. Another developer can open the CSS file and immediately understand the component's visual states.
Tailwind excels at consistency across components. The constrained set of values means spacing, colors, and typography stay uniform without conscious effort. Two developers working on different features will independently choose p-6 for padding because that's the value that looks right in the scale. With CSS Modules, one developer might write padding: 24px while another writes padding: 1.5rem.
The maintenance challenge with Tailwind emerges in complex components. A button with four variants, three sizes, and disabled states can accumulate dozens of utility classes. You extract the variants into objects and use template literals, but the underlying complexity doesn't disappear. It's just organized differently.
const sizeClasses = {
sm: "px-4 py-2 text-sm",
md: "px-6 py-3 text-base",
lg: "px-8 py-4 text-lg",
};
const variantClasses = {
primary: "bg-blue-500 text-white hover:bg-blue-600",
secondary: "bg-transparent text-blue-500 border border-blue-500",
danger: "bg-red-500 text-white hover:bg-red-600",
ghost: "bg-transparent text-gray-700 hover:bg-gray-100",
};
This works. It's clean. But it's still a lot of classes to manage, and you lose some of the "glance at the JSX and know what it looks like" benefit when the classes are stored in objects.
After watching teams adopt both approaches, some patterns emerge.
Designers tend to prefer Tailwind. The constrained design system maps naturally to the way designers think about spacing, typography, and color. When a designer says "the padding should be 24px," the developer reaches for p-6. The translation layer between design tools and code gets thinner.
Full-stack developers and backend-leaning engineers often prefer Tailwind for the same reason. It reduces the cognitive load of styling. You don't need to be a CSS expert to make things look good. The constraints guide you toward reasonable decisions.
Frontend specialists and CSS enthusiasts often prefer CSS Modules. They want the full expressiveness of CSS. Container queries, complex selectors, animations, custom properties — the entire CSS language is available without any abstraction getting in the way.
The team dynamic matters more than individual preference. If you have a design system team maintaining shared components, CSS Modules give them the control they need. If you have feature teams shipping end-to-end, Tailwind reduces the friction between "it works" and "it looks good."
I worked on an e-commerce project a couple years ago that started with CSS Modules. The team was small, everyone knew CSS, and the component library was growing steadily. For the first few months, everything felt fine.
Then we hired two new developers. Then two more. Then the design team grew and started requesting more frequent visual updates. Suddenly we had six people writing CSS across forty components, and the inconsistencies started piling up. One developer used padding: 20px. Another used padding: 1.25rem. Both looked fine in isolation. Together, the pages felt subtly misaligned.
We didn't switch to Tailwind wholesale — that would have been a massive rewrite. But we did adopt Tailwind for new feature work while keeping CSS Modules for the existing component library. The interesting part was watching which approach new developers gravitated toward. Almost universally, they reached for Tailwind for new components. The CSS Modules became legacy code that nobody wanted to touch.
The lesson wasn't that Tailwind is better. It was that Tailwind's constraints become more valuable as team size grows. The things that feel restrictive as a solo developer — fixed spacing scales, limited color values, no custom class names — become guardrails that keep a growing team aligned.
You don't have to pick one. Tailwind and CSS Modules can coexist in the same project. Use Tailwind for layout, spacing, typography, and the 80% of styling that fits neatly into utility classes. Use CSS Modules for the 20% that doesn't — complex animations, deeply nested selectors, component-specific behaviors that don't map to utility classes.
/* ComplexAnimation.module.css */
.entering {
animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform-origin: top center;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scaleY(0.8) translateY(-8px);
}
to {
opacity: 1;
transform: scaleY(1) translateY(0);
}
}
Tailwind handles the box model, colors, and typography. CSS Modules handle the complex animation that would be unwieldy as inline styles or arbitrary Tailwind values. Both approaches contribute what they're good at.
This hybrid approach also works well with third-party components. If you're using something like React Aria Components, you can style them with Tailwind for the base styles and reach for CSS Modules when you need something that Tailwind's utilities don't cover.
CSS Modules make sense when your team has strong CSS skills. If everyone understands specificity, cascade, and modern CSS features, CSS Modules give them the full power of the language without arbitrary constraints.
Small teams building a component library benefit from CSS Modules. The scoping prevents leaks, and the co-location keeps styles organized. There's no framework to configure, no build tool plugins to maintain beyond what your bundler already supports.
Projects with heavy animation requirements also favor CSS Modules. Complex keyframe animations, transform chains, and CSS-only interactive states are easier to write and debug in actual CSS rather than translated through utility classes.
If you've been working with CSS Grid for responsive layouts, you already know that some CSS features are expressive enough that adding an abstraction layer on top of them doesn't help. CSS Modules let you use those features directly.
Tailwind wins when consistency matters more than expressiveness. Growing teams, projects with multiple contributors, codebases that will be maintained for years. The constraints that feel limiting at first become the reason the codebase stays maintainable.
Rapid prototyping favors Tailwind. The speed of styling without leaving your JSX is real. You can build a polished UI in hours that would take days with a traditional CSS workflow.
Projects where developers and designers share a design system also favor Tailwind. The configuration file becomes a source of truth for spacing, colors, breakpoints, and typography. Changes to the design system propagate through the application by updating the config, not by finding and replacing values across hundreds of CSS files.
CSS Modules and Tailwind both produce maintainable, performant applications. The difference is in how they feel to use, what they optimize for, and what frustrations they eliminate.
CSS Modules eliminate naming conflicts and global scope issues. You write real CSS. The full language is available. The tradeoff is that you're still writing CSS — separate files, naming things, managing the gap between styles and markup.
Tailwind eliminates context switching and design inconsistency. You style everything inline with a constrained set of values. The tradeoff is verbosity and the loss of CSS features that don't map neatly to utility classes.
Neither approach is going anywhere. Both have healthy ecosystems and active communities. The right choice depends on your team, your project, and which set of tradeoffs you'd rather live with.
If you've been exploring how Astro and Tailwind work together for performance, you'll see that the styling approach you choose interacts with your broader architecture decisions. The best stack is the one where every layer — framework, styling, data — works the way your team thinks.
Trying to decide on a styling approach for your next project? Red Surge Technology helps teams evaluate their options and set up workflows that scale. Get in touch to talk through your project's needs.