React CSS Modules vs styled-components: A Practical Guide
A practical, up-to-date comparison of React CSS Modules vs. styled-components across DX, performance, theming, SSR, and scalability.
Image used for representation purposes only.
Overview
Choosing between CSS Modules and styled-components in React often comes down to trade-offs: runtime cost vs. flexibility, build-time extraction vs. runtime generation, and team preferences around authoring CSS vs. co-locating styles in JavaScript. This guide compares both approaches across ergonomics, performance, theming, server‑side rendering, testing, and long‑term maintainability, with practical examples and a decision checklist to help you pick confidently.
What they are
- CSS Modules: Regular CSS (or Sass/PostCSS) files where class names are locally scoped by default. Your bundler compiles them to unique hashed class names and extracts a CSS asset per chunk.
- styled-components: A CSS‑in‑JS library that lets you write component-scoped styles in template literals. Styles are generated and injected at runtime (with SSR support) and can react to component props.
Both give you style encapsulation and co-location with components. The core difference is when and how styles are produced: CSS Modules at build time, styled-components at runtime.
Quick start examples
CSS Modules
Button.module.css:
.button {
padding: 0.6rem 1rem;
border-radius: 0.5rem;
border: 1px solid transparent;
font-weight: 600;
cursor: pointer;
transition: background 160ms ease, color 160ms ease, border-color 160ms ease;
}
.primary {
background: var(--color-primary);
color: white;
}
.ghost {
background: transparent;
color: var(--color-primary);
border-color: var(--color-primary);
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
Button.tsx:
import clsx from "clsx";
import styles from "./Button.module.css";
type Variant = "primary" | "ghost";
type Props = {
variant?: Variant;
disabled?: boolean;
children: React.ReactNode;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export function Button({ variant = "primary", disabled, children, className, ...rest }: Props) {
return (
<button
className={clsx(styles.button, styles[variant], disabled && styles.disabled, className)}
disabled={disabled}
{...rest}
>
{children}
</button>
);
}
styled-components
Button.tsx:
import styled, { ThemeProvider } from "styled-components";
type Variant = "primary" | "ghost";
type ButtonProps = { variant?: Variant } & React.ButtonHTMLAttributes<HTMLButtonElement>;
const Button = styled.button<ButtonProps>`
padding: 0.6rem 1rem;
border-radius: 0.5rem;
border: 1px solid transparent;
font-weight: 600;
cursor: pointer;
transition: background 160ms ease, color 160ms ease, border-color 160ms ease;
background: ${({ theme, variant = "primary" }) =>
variant === "primary" ? theme.colors.primary : "transparent"};
color: ${({ theme, variant = "primary" }) =>
variant === "primary" ? "white" : theme.colors.primary};
border-color: ${({ theme, variant = "primary" }) =>
variant === "ghost" ? theme.colors.primary : "transparent"};
opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
`;
const theme = { colors: { primary: "#4f46e5" } };
export function ThemedButton(props: ButtonProps) {
return (
<ThemeProvider theme={theme}>
<Button {...props} />
</ThemeProvider>
);
}
Developer experience (DX)
- Authoring model
- CSS Modules: You write standard CSS (or Sass). Great if your team already thinks in CSS. Class composition and media queries are familiar.
- styled-components: Styles live next to logic inside the component file, with full access to props and JavaScript expressions. Easy to express variants and conditional rules.
- Tooling
- CSS Modules: Leverages your existing PostCSS/Sass pipeline. Autoprefixing and linting are conventional. TypeScript users can generate .d.ts for class names.
- styled-components: Works out of the box. A Babel plugin improves debugging (component display names) and can optimize styles. Built-in vendor prefixing via its styling engine.
- Debugging
- CSS Modules: Dev builds often retain readable class names (e.g., Button_button__abc12). You inspect classes in DevTools like any site.
- styled-components: Dev mode labels classes with component names (e.g., sc-Button-abc12). You can jump from DOM to component easily.
Dynamic styling and variants
- CSS Modules
- Conditional styling is applied by toggling classes. For more granular dynamic values (e.g., runtime colors or sizes), prefer CSS variables and set them inline or via theme scopes.
- Example with CSS variables:
/* theme.css */ :root { --color-primary: #4f46e5; } [data-theme="dark"] { --color-primary: #a78bfa; }// Wrap a subtree and flip data-theme to switch themes <section data-theme="dark"> <Button>Dark Themed</Button> </section>
- styled-components
- Props directly influence styles. Themes are first‑class via ThemeProvider, and tokens are available everywhere styles are defined.
- Useful for algorithmic styles (e.g., computing spacing from props) without extra class juggling.
Performance implications
- CSS cost model
- CSS Modules: Styles are compiled at build time and typically extracted into CSS assets. No runtime library is needed, and the browser caches CSS efficiently across navigations. Great for performance-sensitive surfaces and large apps.
- styled-components: Styles are generated on the client (and/or server during SSR) and injected into the document. This adds a small runtime and some work per styled component instance, especially when styles depend on props. In practice, this overhead is acceptable for many apps but matters on very constrained budgets or very large component trees.
- Code splitting and critical CSS
- CSS Modules: Works naturally with bundler chunking. You might still ship non‑critical CSS for a route unless you add critical‑CSS extraction.
- styled-components: Only injects styles for components actually rendered, which can reduce unused CSS. With SSR, you can inline the critical styles for the initial view.
- Caching trade‑offs
- CSS Modules: External CSS files are highly cacheable and parallelizable.
- styled-components: Styles are inlined in the HTML or injected via JS; caching happens at the page level rather than as a shared CSS asset.
Server‑side rendering (SSR) and hydration
- CSS Modules
- SSR frameworks like Next.js handle CSS Modules seamlessly; styles are included with the server-rendered HTML and linked as CSS files or added as style tags per chunk.
- styled-components
- Requires collecting styles during SSR and injecting them into the HTML. A minimal pattern:
import { ServerStyleSheet } from "styled-components"; export async function render(url: string) { const sheet = new ServerStyleSheet(); try { const html = renderToString(sheet.collectStyles(<App />)); const styles = sheet.getStyleTags(); return `<!doctype html><html><head>${styles}</head><body>${html}</body></html>`; } finally { sheet.seal(); } } - Done right, this eliminates style flicker and improves first paint. Be sure to mirror the server theme on the client during hydration.
- Requires collecting styles during SSR and injecting them into the HTML. A minimal pattern:
Maintainability at scale
- Architecture
- CSS Modules: Encourages separation of concerns—layout in CSS, logic in TS/JS. Scales nicely with design tokens expressed as CSS variables. Global constraints (typography, spacing) can live in a base stylesheet, with components opt‑in via variables/classes.
- styled-components: Promotes encapsulation; each component carries its styles. Building a design system is ergonomic—variants become props, and shared primitives can be extended.
- Refactoring
- CSS Modules: Since class names are imported members, renames are type‑safe with TS typings and flagged by bundlers when missing.
- styled-components: Renaming styled primitives is straightforward. Be mindful of over‑nesting and prop bloat in complex variants.
- Team skill set
- If your team is strong in CSS architecture (BEM, ITCSS, utility patterns), CSS Modules feels natural.
- If your team prefers component‑driven APIs and prop‑based variants, styled-components fits like a glove.
Testing strategies
- Visual and interaction testing works the same for both (Storybook, Playwright, Cypress).
- Unit tests
- CSS Modules: Class presence assertions (e.g., expect(button).toHaveClass(styles.primary)). Snapshot tests remain stable because class names are deterministic in test envs.
- styled-components: You can assert on styles via computed styles or by targeting data attributes. Snapshot tests can include generated class names; consider using serializers that improve readability.
Accessibility and semantics
Both approaches are agnostic to semantics—you decide the underlying element. styled-components offers an “as” prop for polymorphism; use it responsibly and ensure the resulting element remains accessible. With CSS Modules, since you attach classes to native elements directly, it’s straightforward to keep markup semantic.
Interoperability with other styling strategies
- Utility-first CSS (e.g., Tailwind): Can pair with either. Many teams combine Tailwind for broad utility and CSS Modules for component specifics, or Tailwind plus styled-components for variant logic.
- Preprocessors: CSS Modules works great with Sass/PostCSS features. styled-components can import mixins via JS or reuse tokens via theme objects.
- Token systems: Express tokens as CSS variables (for CSS Modules) or as a TypeScript theme type (for styled-components). Both integrate well with design tokens pipelines.
Common pitfalls and how to avoid them
- Overly dynamic styles
- styled-components: Avoid computing large dynamic strings per render. Prefer variant enums and memoized style blocks where possible.
- CSS Modules: Don’t fall back to inline styles for everything—prefer CSS variables so styles still live in CSS and benefit from the cascade and media queries.
- Specificity creep
- Both: Keep selectors shallow. Favor composition via multiple classes (Modules) or styled(Button)
...with careful overrides (styled-components). Avoid deep nesting.
- Both: Keep selectors shallow. Favor composition via multiple classes (Modules) or styled(Button)
- Global leakage
- CSS Modules: Use :global only for deliberate escapes (e.g., third‑party resets). Keep global.css minimal.
- styled-components: Limit createGlobalStyle to true globals (base, resets). Component styles should stay component-scoped.
When to choose CSS Modules
- You want minimal runtime overhead and excellent browser caching.
- Your app emphasizes traditional CSS workflows (Sass, PostCSS plugins, design tokens as CSS variables).
- You value extracted CSS for performance budgets, CDNs, and shared caching across routes.
- You prefer build-time guarantees and simpler SSR setup.
When to choose styled-components
- You need rich, prop‑driven variants and algorithmic styling.
- You want first‑class theming with a single source of tokens and tight coupling to component logic.
- You prefer styles to be co-located and versioned with components in one file.
- You want critical CSS inlined during SSR without extra tooling and styles loaded strictly on what renders.
Migration tips
- CSS Modules → styled-components
- Start with leaf components that need dynamic variants. Keep global/base CSS as is. Gradually move tokens into a theme object.
- styled-components → CSS Modules
- Extract stable styles to Modules; keep truly dynamic parts as inline styles or CSS variables. Replace ThemeProvider with a CSS variables theme scope.
- Mixed setups are fine: Many production apps blend both—use styled-components for a design system layer (Button, Input, Card) and CSS Modules for app‑specific screens.
Decision checklist
Answer these with your team:
- Performance budget tight and need zero runtime styling? Choose CSS Modules.
- Heavy use of prop‑based variants and theming? Choose styled-components.
- Prefer CSS authoring tools (Sass, PostCSS) and design tokens as CSS variables? CSS Modules.
- Need styles to respond directly to React state without class juggling? styled-components.
- Optimizing for long‑term caching across routes/CDNs? CSS Modules.
- Want SSR with automatic critical styles scoped to what renders? styled-components.
Final thoughts
Both CSS Modules and styled-components are proven in large-scale React apps. Pick the one that aligns with your performance targets and the way your team thinks about UI. If you’re undecided, try a hybrid: implement your shared design system with styled-components for expressive variants, and style application pages with CSS Modules for minimal runtime overhead. Measure, iterate, and let data—not dogma—make the final call.
Related Posts
Mastering Complex State in React with useReducer
A practical guide to complex React state with useReducer: modeling, actions, async flows, context patterns, performance, and testing.
React Dark Mode Theme Toggle: A Modern, Accessible Tutorial
Build a robust React dark mode with CSS variables, system preference, SSR-safe setup, persistence, accessibility, and Tailwind integration.
Apollo vs Relay: Choosing the Right GraphQL Client for React
Apollo vs Relay: strengths, trade‑offs, and when to choose each for React GraphQL apps, from caching and pagination to SSR, typing, and developer UX.