Tailwind vs CSS Modules: What We Ended Up Doing
How our team debated and resolved the Tailwind vs CSS Modules question. We didn't pick just one.

Our frontend team had one of those arguments that never fully resolves โ the kind where both sides have valid points and the "right answer" depends on values more than facts. The question was whether to migrate our component library from CSS Modules to Tailwind CSS. We'd been using CSS Modules for about two years and they worked fine, but a couple of team members had used Tailwind on side projects and were pushing to adopt it.
The conversation went back and forth for weeks before we landed on a decision. I want to capture the arguments because I think they're representative of what a lot of frontend teams deal with. And the answer we arrived at wasn't what either camp expected.
The Case for CSS Modules (What We Had)
CSS Modules had served us well. For anyone unfamiliar, the basic idea is that each component gets its own .module.css file, and the class names in that file are locally scoped โ they get hashed at build time so they can never collide with classes from other components. You import the styles like a JavaScript object and use them as class names.
import styles from './Card.module.css';
function Card({ title, children }) {
return (
<div className={styles.card}>
<h2 className={styles.title}>{title}</h2>
<div className={styles.body}>{children}</div>
</div>
);
}
/* Card.module.css */
.card {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1.5rem;
}
.title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.body {
color: #4a5568;
line-height: 1.6;
}
The advantages were real. Complete style isolation โ you never worried about CSS leaking between components. The CSS files were readable and well-organized. You could use all of CSS's features, including complex selectors, animations, and media queries, without any abstraction layer. For developers who know CSS well, it felt natural.
The downsides were also real, though we didn't fully acknowledge them until the Tailwind camp started pushing back. Finding dead CSS was hard โ how do you know if a class in a module file is still being used? You'd have to search the codebase for references. The files accumulated cruft over time, especially for components that had been refactored multiple times. Context-switching between the .tsx file and the .module.css file added friction. Not a lot, but it's there. Every time you want to change how something looks, you're bouncing between two files.
And here's the one that actually bothered me: design token inconsistency. We'd defined a color palette and spacing scale in CSS custom properties, but nothing stopped a developer from typing color: #3b82f6 instead of color: var(--blue-600). Over two years, we'd accumulated 14 slightly different shades of blue because people kept hardcoding values. I found them by running a grep through all the module files. Fourteen blues. Some of them were one hex digit apart, visually indistinguishable, but technically different values in the code.
The Case for Tailwind
The Tailwind proponents had a compelling pitch. Tailwind CSS is a utility-first framework where you style elements by combining small, single-purpose classes directly in your markup. Instead of writing custom CSS for each component, you use pre-defined classes.
function Card({ title, children }) {
return (
<div className="border border-slate-200 rounded-lg p-6">
<h2 className="text-xl font-semibold mb-3">{title}</h2>
<div className="text-slate-600 leading-relaxed">{children}</div>
</div>
);
}
No CSS file. No separate stylesheet. Everything is in the component file. The utility classes are standardized โ text-xl is always the same font size, border-slate-200 is always the same border color. The design token problem we had with CSS Modules would vanish because you can't accidentally use a non-standard value when your options are constrained to the framework's scale.
Dead CSS becomes a non-issue too. Tailwind's build process scans your source files and only includes the CSS for classes that are actually used. If you remove a class from the markup, it's gone from the output. Zero waste. Our CSS bundle with Modules was about 85KB. The Tailwind team estimated we could get to around 10KB.
The counterarguments were mostly about readability. A complex component in Tailwind can end up with very long class strings:
<button className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-blue-600 text-white hover:bg-blue-700 h-10 px-4 py-2">
Submit
</button>
That's a lot of classes on one element. The CSS Modules version would have been className={styles.submitButton} โ three words. Reading the Tailwind version requires parsing 15+ utility names to understand what the button looks like.
The Tailwind camp's response to this was that you get used to reading utility classes quickly, and that the co-location benefit โ seeing ALL the styles right on the element instead of cross-referencing a separate file โ outweighs the visual noise. Having worked with Tailwind for several months now, I'd say they're partially right. I did get used to reading it. But I can't honestly say that className="inline-flex items-center justify-center rounded-md..." is as scannable as className={styles.submitButton}. It's just not. I've accepted that as a tradeoff I'm willing to make, but it IS a tradeoff.
Responsive Design: Different Philosophies
One area where the teams genuinely disagreed was how to handle responsive styles. In CSS Modules, you group media queries at the bottom of the file or nest them inside relevant selectors. The responsive behavior is organized separately from the base styles, which can be either a feature or a bug depending on how you look at it.
In Tailwind, responsive behavior is inline:
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
The element goes from 1 column on mobile to 2 on medium screens to 4 on large screens. It's all right there. Proponents say this is better because you can see the full responsive behavior of an element without scrolling to a media query somewhere else. Critics say it makes the class string even longer and harder to parse.
I've found that for simple responsive changes โ column counts, visibility toggles, padding adjustments โ the inline approach works well. For complex responsive redesigns where a component's entire layout changes at a breakpoint, I think a separate CSS file is easier to reason about. Not everything fits neatly into utility classes.
What We Actually Decided
We argued about this for too long before someone suggested the obvious compromise: use both.
Tailwind for component library primitives โ buttons, cards, inputs, badges, form elements. These are standardized UI elements where Tailwind's enforced design tokens are a clear win and the styling is simple enough that utility classes keep things readable. Having consistent colors and spacing across every button in the app is more valuable than having clean CSS files for each button variant.
CSS Modules for complex, layout-heavy components โ the dashboard grid with custom calculations, the interactive data table, the canvas-based drawing tool. Things with heavy calc() usage, complex animations, or layouts that require more CSS than fits comfortably in utility classes. Tailwind isn't great for these cases, and trying to force it leads to @apply usage everywhere, which defeats the purpose.
The split has worked reasonably well. Maybe 70% of our components use Tailwind now. The remaining 30% use CSS Modules. The boundary between them is a bit fuzzy and there's occasional debate about which bucket a new component falls into, but it's manageable.
What I'd Change Looking Back
If I were starting a new project from scratch today, I'd use Tailwind from the beginning. The design token enforcement alone is worth it for any team larger than one person. Catching the 14 shades of blue problem before it starts is more valuable than any readability concern.
But I wouldn't use Tailwind for everything. There's a certain complexity threshold where utility classes stop being helpful and start being noise. Complex animations, intricate grid layouts with dynamic calculations, pseudo-element styling โ for these, writing actual CSS in a scoped file is still more natural.
The thing nobody tells you about Tailwind is how it changes your workflow in subtle ways. You stop switching between files, which is nice. But you start spending time in the Tailwind documentation looking up class names, especially for less common utilities. The cognitive load shifts from "which file has this component's styles" to "what's the Tailwind class for line-height: 1.75" (it's leading-7, which is not obvious). After a few months you memorize the common ones and it gets faster. But the ramp-up period is real.
I also want to mention โ the argument between Tailwind and CSS Modules is a lot less important than having consistent patterns across your team, whichever approach you pick. The worst outcome isn't choosing the "wrong" framework. It's having half the team using utility classes and the other half writing custom CSS with no shared conventions. Consistency matters more than the specific tool. We got close to that worst outcome during our debate and it would have been worse than either approach applied consistently.
Written by
Anurag Sinha
Developer who writes about the stuff I actually use day-to-day. If I got something wrong, let me know.
Found this useful?
Share it with someone who might find it helpful too.
Comments
Loading comments...
Related Articles
CSS Modern Layouts: What Actually Clicked for Me
Addressing the most common points of confusion regarding CSS Grid, Flexbox, Container Queries, and logical properties.
Accessibility Bugs I Keep Finding in Web Apps
The most frequent accessibility violations I encounter in code reviews, why they matter, and the specific fixes.
What We Learned Migrating to React Server Components
Notes from migrating our SPA to the Next.js App Router and React Server Components. What improved, what broke, and what surprised us.