CSS Modules vs Tailwind for a Component Library
Tony Jiang · · 5 min read · permalink
Building a component library forces a choice that doesn’t come up in most application work: you’re not just styling components for yourself, you’re shipping styles that someone else’s project has to consume. That constraint changes the calculus significantly.
- CSS Modules — scoped class names, separate
.module.cssfiles, compiles to plain CSS. - Tailwind — utility classes applied directly in markup, styles co-located with the component, requires Tailwind in the consumer’s project.
Both are reasonable for application work. For a library, the tradeoffs are sharper.
CSS Modules
The mental model
Each component gets its own .module.css file. The build tool transforms class names like .button into something globally unique like .Button_button__3xYz, so styles never leak. Consumers import your compiled package and get a plain CSS file alongside the JS — no additional tooling required.
/* Button.module.css */
.button {
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: 500;
}
.primary {
background: var(--color-primary);
color: white;
}
import styles from './Button.module.css';
export function Button({ variant = 'primary', children }) {
return <button className={`${styles.button} ${styles[variant]}`}>{children}</button>;
}
What this gives you for a library
Zero consumer setup. Your compiled output is a JS bundle and a CSS file. Consumers import both and it works — no Tailwind config, no PostCSS plugins, no content globs. This is a significant advantage when your library might be consumed by projects with very different setups.
Predictable output. The CSS file you ship is the CSS file consumers get. File size grows linearly with the number of components, not with how many utility classes you use.
Theming through custom properties. CSS custom properties integrate naturally — expose --color-primary, --radius-md, etc. from the library, and consumers override them to match their brand. This is a clean API boundary.
Explicit overrides. When a consumer needs to customize a specific component, they know exactly what they’re targeting. The compiled class names are stable and predictable.
Where it falls short
Verbose for complex variant logic. A button with five sizes and four variants means a lot of generated class name combinations that you’re assembling manually. Libraries like clsx help, but you’re still writing the logic yourself.
Context-switching between the .jsx and .module.css file slows down iteration. Not a dealbreaker, but it adds friction.
Tailwind
The mental model
Styles live directly in the markup as utility classes. No separate CSS file per component. The library ships the class names; Tailwind’s JIT compiler is responsible for generating the corresponding CSS.
export function Button({ variant = 'primary', children }) {
const base = 'px-4 py-2 rounded font-medium transition-colors';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
};
return <button className={`${base} ${variants[variant]}`}>{children}</button>;
}
For variant management at scale, cva (class-variance-authority) is the standard approach — it handles the combinatorial class logic cleanly and provides TypeScript types for free.
import { cva } from 'class-variance-authority';
const button = cva('px-4 py-2 rounded font-medium transition-colors', {
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
},
size: {
sm: 'text-sm px-3 py-1.5',
md: 'text-base px-4 py-2',
lg: 'text-lg px-6 py-3',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
});
What this gives you for a library
Variant logic is cleaner. cva handles the combinatorial problem well. Adding a new size or variant is one line, not a new CSS rule plus new class name logic.
Design tokens are built in. Tailwind’s scale (text-sm, gap-4, rounded-lg) enforces consistency — it’s harder to accidentally introduce a one-off value because you have to explicitly break out of the scale to do so.
Co-location. The full visual specification of a component is in one file. This makes components easier to audit and copy-paste.
Where it falls short
Consumer setup is a hard requirement. This is the fundamental issue. Tailwind works by scanning source files for class names and generating only the CSS that’s used. If your library ships pre-built class name strings, consumers need to include your library’s source in their Tailwind content configuration:
// tailwind.config.js (in the consumer's project)
module.exports = {
content: [
'./src/**/*.{js,jsx,ts,tsx}',
'./node_modules/your-library/dist/**/*.js', // required
],
};
If they’re not using Tailwind, they can’t use your library at all without a workaround. If they’re on a different Tailwind version, you can ship a Tailwind preset to try to align configurations, but it’s additional friction.
Customization surface is limited. A consumer can’t easily override a specific style within a component — there’s no semantic class name to target. They can extend via class merging (with tailwind-merge) or by forking the component, but neither is as clean as overriding a CSS custom property.
Class name conflicts. If the consumer has custom Tailwind utilities or a different base configuration, your hardcoded class names might resolve differently than you expect.
The comparison that matters for libraries
| CSS Modules | Tailwind | |
|---|---|---|
| Consumer setup | None — just import the CSS | Tailwind required, content config needed |
| Theming | CSS custom properties | Tailwind config / preset |
| Variant logic | Manual, verbose | cva handles it cleanly |
| Customization | Override CSS custom properties | Class merging with tailwind-merge |
| Bundle | Standalone CSS file | Consumer’s Tailwind generates the CSS |
| Design consistency | Enforced by your tokens | Enforced by Tailwind’s scale |
What to actually choose
Choose CSS Modules if your library needs to work in any React project regardless of that project’s CSS setup. The zero-setup consumption and clean theming API (CSS custom properties) make it the safer default. If you’re publishing to npm for general use, this is almost always the right call.
Choose Tailwind if your library is internal and every consumer already uses Tailwind, or if you’re building a design system for a specific product ecosystem. The variant ergonomics (cva) and built-in design constraints are genuinely better, and if the setup cost is already paid, there’s no reason to give that up.
The practical reality: most well-used open-source component libraries (Radix, Headless UI, React Aria) ship unstyled or with CSS Modules specifically because they can’t make assumptions about the consumer’s stack. Tailwind-first libraries (shadcn/ui, Flowbite) work by giving you the source to copy rather than a package to import — which sidesteps the setup problem by making you own the code. That’s a different model than a traditional component library, and it’s worth being intentional about which one you’re building.