What are the different approaches to styling React components and when do you use each?
Six approaches: (1) plain CSS / SASS files; (2) CSS Modules — scoped class names per file; (3) Tailwind / Atomic CSS — utility classes; (4) CSS-in-JS (styled-components, emotion) — JS-authored styles with dynamic props; (5) zero-runtime CSS-in-JS (vanilla-extract, Linaria, Panda); (6) framework-native (Next.js global + module CSS). Modern default: Tailwind or CSS Modules. Avoid runtime CSS-in-JS for hot paths.
Six approaches, each with tradeoffs.
1. Plain CSS / SASS
.button { background: blue; }import './Button.css';
<button className="button">Click</button>Pro: zero abstraction; everyone knows it. Con: global namespace — class collisions in big apps.
2. CSS Modules
/* Button.module.css */
.button { background: blue; }import styles from './Button.module.css';
<button className={styles.button}>Click</button>Pro: locally scoped class names; co-located with component; no runtime overhead. Con: no dynamic values from props (workaround: data attributes + CSS custom properties).
Default for many production apps.
3. Tailwind / Atomic CSS
<button className="bg-blue-500 hover:bg-blue-700 text-white px-4 py-2 rounded">
Click
</button>Pro: no naming, no file switching, design-system tokens enforced, JIT compiler ships only used classes (~10 KB), excellent autocompletion via Tailwind IntelliSense. Con: JSX gets visually dense; cosmetic refactors mean rewriting long strings (clsx helps).
4. CSS-in-JS (styled-components, emotion)
import styled from 'styled-components';
const Button = styled.button`
background: ${props => props.$primary ? 'blue' : 'gray'};
padding: 8px 16px;
`;
<Button $primary>Click</Button>Pro: dynamic styles via props, JS-authored. Con: runtime cost (style injection on render), SSR complications, bundle bloat, less mature in RSC.
The React team has publicly cooled on runtime CSS-in-JS for new projects.
5. Zero-runtime CSS-in-JS
vanilla-extract, Linaria, Panda, StyleX — write styles in TS/JS, compile to static CSS at build time.
// styles.css.ts (vanilla-extract)
import { style } from '@vanilla-extract/css';
export const button = style({
background: 'blue',
padding: '8px 16px',
});import * as styles from './styles.css';
<button className={styles.button}>Click</button>Pro: typed styles, build-time extraction, no runtime cost. Con: build tooling, less ergonomic for one-offs than Tailwind.
6. Framework-native (Next.js)
Next.js supports CSS Modules, Tailwind, Sass, and global CSS out of the box. App Router adds CSS in RSC contexts without extra setup.
Recommendation matrix
| Project | Best |
|---|---|
| New SPA, design system to build | Tailwind |
| Component library, distributable | CSS Modules or Linaria (zero-runtime) |
| Heavy theming / dynamic styles | CSS variables + Tailwind, or vanilla-extract |
| Legacy migration | Whatever's already there, plus CSS Modules for new code |
| RSC-heavy app | CSS Modules + Tailwind (runtime CSS-in-JS struggles) |
Theming
Modern recommendation: CSS custom properties.
:root { --primary: #3b82f6; }
[data-theme='dark'] { --primary: #60a5fa; }<button style={{ background: 'var(--primary)' }}>Click</button>Switching theme is one DOM attribute change; no JS rerender required.
Class composition utilities
For Tailwind especially:
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
const cn = (...args) => twMerge(clsx(args));
<button className={cn('px-4 py-2', primary && 'bg-blue-500')}>...</button>Senior framing
Styling choice is downstream of team and project. Tailwind dominates new green-field projects; CSS Modules is the conservative default; CSS-in-JS is fading for runtime reasons; zero-runtime alternatives are gaining. Pick once, document, enforce — fragmentation across the codebase is the real cost.
Follow-up questions
- •Why has the React team cooled on runtime CSS-in-JS?
- •When would you pick CSS Modules over Tailwind?
- •How do zero-runtime CSS-in-JS libraries work?
Common mistakes
- •Mixing four styling approaches in one codebase.
- •Picking CSS-in-JS in 2025 without checking RSC compatibility.
- •Hand-writing 200-class Tailwind strings without a clsx/cn helper.
Performance considerations
- •Runtime CSS-in-JS: style injection cost on every render. Tailwind: zero runtime cost; JIT outputs ~10 KB. CSS Modules: zero runtime cost. Zero-runtime libraries: compile-time cost only. Bundle and runtime both matter; profile both.
Edge cases
- •CSS-in-JS + RSC: most runtime libraries don't work in server components.
- •Tailwind + dynamic classes: must include in safelist or use clsx with full string.
- •CSS variables don't cascade through shadow DOM.
Real-world examples
- •Vercel (Tailwind), GitHub (Primer CSS), Linear (custom CSS Modules), Stripe (proprietary). New projects in 2024+ lean Tailwind or vanilla-extract.