Overview
Use CSS Modules for new React projects. They produce zero runtime overhead, work with static extraction at build time, and integrate cleanly with SSR including React Server Components. Use styled-components only when dynamic runtime theming (theme values computed at runtime per user, per tenant, or per user action) is a genuine product requirement that cannot be served by CSS custom properties. See tailwind-vs-css-modules for the adjacent decision between utility-first and module CSS.
When CSS Modules wins
CSS Modules are the default for new projects.
- Zero runtime: styles are extracted to static CSS files at build time. No JavaScript runs at paint time to inject styles. This is a meaningful improvement for Core Web Vitals (Cumulative Layout Shift, Total Blocking Time).
- SSR and RSC compatible: CSS Modules work in Next.js App Router Server Components. styled-components requires the client boundary.
- Scoping: class names are hashed to the file scope automatically. No naming collisions across components.
- Familiarity: the file is plain CSS. Designers, non-frontend engineers, and accessibility auditors can read it without knowing JavaScript or the styled-components API.
- Tooling: Vite, Webpack, and Next.js support CSS Modules out of the box. No additional packages.
- Composition:
composes: base from './base.module.css'enables style reuse without duplication.
// Button.module.css
.button { padding: 8px 16px; border-radius: 4px; }
.primary { background: var(--color-primary); color: white; }
// Button.tsx
import styles from "./Button.module.css";
export const Button = ({ variant = "primary", children }) => (
<button className={`${styles.button} ${styles[variant]}`}>{children}</button>
);When styled-components wins
styled-components is justified when runtime theming is a real product constraint.
- Per-user runtime themes: if theme values (colors, spacing, typography) change at runtime based on user preferences, tenant branding, or live product configuration, styled-components injects the computed CSS in a single paint. CSS custom properties achieve this in many cases too, but styled-components handles deeply nested dynamic values more naturally.
- Interpolated logic:
background: ${(p) => p.active ? colors.active : colors.default}ties visual state directly to props without class-name toggling. Useful when state combinations are numerous. - Design system libraries: packages that export components to consumers without a bundler assumption often use styled-components so consuming apps do not need to configure CSS Module support.
- Existing codebase: a large codebase already on styled-components v6 with an active team. Do not rewrite for its own sake.
Note: styled-components v6 adds a Babel/SWC plugin for SSR support, but the extra dependency chain and client-only boundary remain costs.
Trade-offs at a glance
| Dimension | CSS Modules | styled-components |
|---|---|---|
| Runtime overhead | None (static extraction) | JS runtime for style injection |
| SSR / RSC compatible | Yes | Requires client boundary in RSC |
| Core Web Vitals | Better (no style flicker) | Risk of FOUC without SSR plugin |
| Bundle size | Styles in CSS file; no JS added | ~12 KB JS runtime |
| Dynamic runtime themes | CSS custom properties (limited) | First-class via props and ThemeProvider |
| TypeScript integration | Manual typed class names (@types) | Typed props on template literals |
| Colocation | .module.css alongside component | Template literal in same file |
| Readability for designers | Plain CSS file | JavaScript with CSS-in-JS syntax |
| Vite/Webpack support | Built-in | Plugin or SWC transform |
| Debugging | Class names in DevTools | Generated class names; display name helps |
| Testing | Class name assertions | Rendered styles assertions |
Migration cost
styled-components to CSS Modules migration is file-by-file and safe to do incrementally.
- Extract each
styled.*component to a.module.cssfile. Convert interpolated logic to CSS custom properties or conditional class names. - Replace
ThemeProviderwith CSS custom properties on:rootor a data attribute. Most theme needs are served by this pattern. - Remove
styled-componentsfrompackage.jsononce all components are migrated. Add@types/css-moduleif TypeScript class name inference is needed. - Effort: one engineer-day per 30 styled components. Themes with complex runtime logic take longer.
Recommendation
- New React project: CSS Modules.
- Next.js App Router project: CSS Modules. styled-components requires the
"use client"boundary and loses RSC benefits. - Project with true per-tenant runtime theming (SaaS with custom brand colors): styled-components or CSS custom properties approach. Evaluate CSS custom properties first.
- Existing styled-components project working well: stay. Migrate on a major UI refactor, not as a standalone project.
- Alternative worth evaluating: tailwind-vs-css-modules; Tailwind eliminates the choice entirely for many teams.