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.

The argument went on for weeks. Both sides had valid points. The "right answer" depended on values more than facts, which is why it kept going in circles. The question: should our component library migrate from CSS Modules to Tailwind CSS? We'd been using CSS Modules for two years. They worked fine. But a couple of team members had used Tailwind on side projects and were pushing to switch.
This is a capture of the arguments because they're representative of what a lot of frontend teams go through. And the answer we landed on wasn't what either camp expected.
What CSS Modules Gave Us
Each component gets its own .module.css file. Class names are locally scoped โ hashed at build time so they can't collide with classes from other components. Import styles as a JavaScript object, 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;
}
Real advantages. Complete style isolation โ no CSS leaking between components. Files were readable, well-organized. Full access to CSS features โ complex selectors, animations, media queries โ no abstraction layer. For developers who know CSS well, it felt natural.
The downsides were real too, though we didn't fully acknowledge them until the Tailwind camp pushed back. Finding dead CSS was hard โ how do you know if a class in a module file is still referenced? Search the codebase. Files accumulated cruft over time, especially components that had been refactored multiple times. Context-switching between .tsx and .module.css added friction. Not enormous friction. But it's there. Every visual change means bouncing between two files.
And the problem that bothered me most: design token inconsistency. We'd defined a color palette and spacing scale in CSS custom properties. Nothing stopped a developer from typing color: #3b82f6 instead of color: var(--blue-600). Over two years, roughly 14 slightly different shades of blue accumulated in our module files. Found them by running grep. Fourteen blues. Some one hex digit apart โ visually identical, technically different values in the code.
The Tailwind Pitch
Utility-first framework. Style elements by combining small single-purpose classes directly in markup. No custom CSS for each component. Pre-defined classes instead.
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. Everything in the component. Utility classes are standardized โ text-xl is always the same font size, border-slate-200 is always the same color. The fourteen-blues problem 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 source files and only outputs CSS for classes that are actually used. Remove a class from markup, it's gone from the output. Zero waste. Our CSS bundle with Modules was about 85KB. The Tailwind team estimated around 10KB was achievable.
If you're not sure whether you need a framework at all, I wrote about the native CSS capabilities that have gotten surprisingly good in my modern CSS layouts post. The counterarguments were about readability:
<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>
A lot of classes on one element. CSS Modules version: 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: you get used to reading utility classes quickly, and co-location โ seeing all styles right on the element instead of cross-referencing files โ outweighs the visual noise. After working with Tailwind for several months now, they're partially right. I did adjust. But I can't say className="inline-flex items-center justify-center rounded-md..." is as scannable as className={styles.submitButton}. It's just not. Accepted that as a tradeoff. But it IS a tradeoff, at least in my experience.
Responsive Design: Different Philosophies
Area of real disagreement. CSS Modules groups media queries at the bottom of the file or nests them inside selectors. Responsive behavior organized separately from base styles โ feature or bug depending on perspective.
Tailwind makes responsive behavior inline:
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
One column mobile, two medium screens, four large. Right there on the element. Proponents: see the full responsive behavior without scrolling to a media query elsewhere. Critics: class strings get even longer and harder to parse.
For simple responsive changes โ column counts, visibility toggles, padding adjustments โ the inline approach works well. Complex responsive redesigns where a component's entire layout changes at a breakpoint โ a separate CSS file is easier to reason about. Not everything fits comfortably in utility classes.
Dark Mode, Theming, and Design Tokens
One area where the difference between the two approaches gets magnified: theming. If your app supports dark mode or multiple brand themes, how you manage that varies a lot between CSS Modules and Tailwind.
CSS Modules approach: define your colors as CSS custom properties on :root and a [data-theme="dark"] selector. Components reference var(--bg-primary), var(--text-secondary), etc. Switching themes swaps the custom properties. Works well but requires discipline โ nothing stops someone from hardcoding #ffffff instead of using the variable. Our fourteen-blues problem was exactly this failure mode.
Tailwind approach: configure your tailwind.config.js with color tokens and use dark: variant for dark mode classes. className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white". The dark mode behavior is inline with the element, which keeps it visible. And because Tailwind's color palette is predefined, there's no opportunity to accidentally introduce a fifteenth shade of blue.
We ran into an edge case with our split approach. Some CSS Modules components used custom properties for theming. Some Tailwind components used the dark: variant. Both worked. But they didn't always agree on the exact color values, because the CSS custom properties and the Tailwind config had drifted slightly apart. A button in the Tailwind system used blue-600 as its primary color. A custom component in CSS Modules used var(--primary) which was mapped to a hex value that was close to, but not exactly, Tailwind's blue-600. The difference was invisible to most people. The designer noticed it in a screenshot and filed a bug.
Fix was to derive the CSS custom properties from the Tailwind config using a build step, so there's one source of truth. Extra tooling. But it eliminated the drift.
Performance Differences
Worth addressing because people ask. The performance difference between CSS Modules and Tailwind in production is negligible for most applications. Both produce standard CSS that browsers parse efficiently.
Where there IS a difference: bundle size. CSS Modules ship every class definition you wrote, even unused ones (unless you add purging tooling, which most setups don't). Tailwind's build process scans source files and produces only the CSS for classes that appear in your code. Our Modules bundle was ~85KB. After migrating the majority of components to Tailwind, the combined CSS output dropped to about 18KB. For users on slow connections, that matters โ especially on first load when the CSS blocks rendering.
Build time is the other consideration. Tailwind's JIT compiler adds a step to the build process. On our project it's barely noticeable โ maybe 200ms on top of the existing build. But on larger projects with thousands of components, from what I've seen, it can add a few seconds. Not a dealbreaker, but worth measuring in your own setup before assuming it's free.
What We Actually Chose
Someone suggested the obvious compromise after weeks of arguing. Use both.
Tailwind for component library primitives โ buttons, cards, inputs, badges, form elements. Standardized UI where Tailwind's enforced design tokens are a clear win and styling is simple enough for readable utility classes. Consistent colors and spacing across every button in the app matters more than pristine CSS files per 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 exceed what fits comfortably in utility classes. Forcing Tailwind there leads to @apply usage everywhere, which defeats the purpose.
The split has worked reasonably well. About 70% of components use Tailwind now, if I had to guess. Remaining 30% use CSS Modules. The boundary is a bit fuzzy โ occasional debate about which bucket a new component falls into โ but it's manageable.
Looking Back
Starting a new project from scratch today: Tailwind from the beginning. Design token enforcement alone justifies it for any team larger than one person. Catching the fourteen-blues problem before it starts saves more time than any readability concern costs.
But not Tailwind for everything. A complexity threshold exists where utility classes stop helping and become noise. Complex animations, intricate grid layouts with dynamic calculations, pseudo-element styling โ actual CSS in a scoped file is more natural there.
The thing nobody mentions about Tailwind: how it changes workflow in subtle ways. You stop switching between files. Nice. You start spending time in Tailwind docs looking up class names, especially less common utilities. leading-7 for line-height 1.75 is not obvious. After a few months you memorize the common ones. But the ramp-up period is real.
The argument between these two approaches is less important than having consistent patterns across the team, whichever you pick. Worst outcome isn't the "wrong" framework. It's half the team writing 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. It would have been worse than either approach applied consistently.
The Migration Process
For teams considering this kind of move, here's what the actual migration looked like. We didn't convert everything at once. That would have been a week of the entire frontend team doing nothing but rewriting CSS, which nobody wanted.
Instead, we adopted a rule: new components use Tailwind. Existing components get converted when they're being modified for other reasons. If you're touching a component to add a feature or fix a bug, convert its styling while you're there. If you're not touching it, leave it alone.
This meant the codebase was mixed for about three months. Some components in Modules, some in Tailwind, some partially converted. Messy? Yes. But it meant the migration happened alongside feature work instead of blocking it. By month three, the most-touched components (the ones with the most active development) were all Tailwind. The rarely-touched components stayed in Modules, and nobody felt urgent pressure to convert them.
The conversion process for a single component was usually straightforward: open the .module.css file, read the styles, translate each declaration to Tailwind utility classes, delete the CSS file. A simple component took maybe 15 minutes. Complex components with many responsive styles or conditional classes could take up to an hour.
The gotcha: CSS Modules support things Tailwind doesn't map to directly. Complex ::before and ::after pseudo-elements. Multi-step CSS animations. calc() expressions with custom variables. For these, we either kept a small scoped CSS file alongside the Tailwind classes (using the @apply escape hatch sparingly) or decided the component belonged in the "CSS Modules" bucket.
One team member created a conversion cheat sheet mapping our most-used CSS patterns to their Tailwind equivalents. Saved everyone time during the first couple weeks when Tailwind class names weren't yet in muscle memory. Simple document โ maybe 40 rows โ but it prevented a lot of "what's the Tailwind class for letter-spacing?" searches.
On @apply and Why We Mostly Avoid It
Tailwind has an @apply directive that lets you compose utility classes inside a regular CSS file:
.btn-primary {
@apply bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700;
}
Sounds convenient. In practice, it undermines the main benefit of Tailwind โ co-location. Now you're back to having styles in a separate file, just written in a different syntax. The Tailwind team themselves say to use @apply sparingly, and I agree.
Where we do use it: global reset styles, base typography, and the occasional component that gets used in so many places that the class string would be duplicated dozens of times across the codebase. A shared button component used in 40 files doesn't need the same 15 utility classes copied everywhere โ that's a maintenance burden. Extract it with @apply or, better, create a React component with the classes built in.
But @apply for every component? That's just CSS Modules with extra steps.
Onboarding New Developers
Something we didn't anticipate: the hybrid approach affects how new team members ramp up. A developer joining the team has to learn two styling systems and understand when each is used. During the first sprint, a new hire styled a complex dashboard widget entirely in Tailwind because they assumed it was the default for everything. The class strings became unmanageable and the PR got bounced back. Another new hire went the other direction โ wrote CSS Modules for a simple card component that should have been three Tailwind utility classes.
We fixed this by adding a short decision tree to our onboarding docs. Does the component involve complex animations, calc() expressions, or intricate pseudo-elements? CSS Modules. Everything else? Tailwind. Takes about two minutes to read. Saved hours of back-and-forth in code review during the first month.
The bigger lesson: any architectural decision that requires judgment calls at the component level needs documentation aimed at people who weren't in the room when the decision was made. The people who debated for weeks have all the context in their heads. The person who joins six months later has none of it. A half-page document with three concrete examples bridges that gap better than any amount of tribal knowledge passed through code review comments.
We also created a small set of reference components โ one Tailwind button, one CSS Modules dashboard panel, one hybrid component โ that new developers could study before writing their first PR. Pattern matching from real examples beats abstract rules every time. People looked at the reference components and immediately understood the boundary better than any written guideline could explain.
Consistency Over Choice
One thing worth keeping in mind regardless of styling approach: make sure your components are accessible. Utility classes can make it easy to forget about color contrast and semantic HTML when you're focused on visual design.
The broader takeaway from our migration: the styling tool matters less than the team's commitment to using it consistently. A codebase where everyone uses CSS Modules the same way is better than a codebase where half the team uses Tailwind and the other half uses CSS Modules and a third person is writing styled-components because they liked it at their last job. The worst bugs in our styling history weren't caused by the wrong tool โ they were caused by inconsistent application of whatever tool was chosen.
Pick one approach (or a deliberate hybrid like ours). Document the conventions. Enforce them in code review. That's the part that actually matters.
Written by
Anurag Sinha
Full-stack developer specializing in React, Next.js, cloud infrastructure, and AI. Writing about web development, DevOps, and the tools I actually use in production.
Stay Updated
New articles and tutorials sent to your inbox. No spam, no fluff, unsubscribe whenever.
I send one email per week, max. Usually less.
Comments
Loading comments...
Related Articles

Browser DevTools โ Way Beyond console.log
The debugging features hiding in plain sight that took me years to discover. Performance profiling, memory leak hunting, network simulation, and the snippets panel I now use daily.

Frontend Testing โ What to Actually Test and What's a Waste of Time
My honest take on unit vs integration vs e2e testing after years of writing tests that caught nothing and missing tests that would have caught everything.

Web Performance That Actually Matters โ Beyond Lighthouse Scores
What actually moves the needle on real user experience: fixing LCP, CLS, and INP with changes that users notice, not just scores that improve.