Skip to main content

Command Palette

Search for a command to run...

CSS-in-JS Is Dead (And That's Okay): A Retrospective & Migration Guide

Published
17 min read

TL;DR: Runtime CSS-in-JS (styled-components, Emotion) is functionally incompatible with React Server Components, adds 12–35ms of render overhead per page, and bloats your JS bundle by 12–30KB gzipped. The ecosystem has moved on. Your team should too. This post explains why, what won, and how to migrate — without a big-bang rewrite.


The Stat That Should Make You Uncomfortable

A large e-commerce platform with 500+ React components measured 2.8 seconds initial load with CSS-in-JS versus 2.1 seconds with CSS Modules — a 33% slower experience for every user, on every page load, every day. On mobile with a constrained CPU, that gap widens further.

That's not a micro-benchmark in a lab. That's production data. And it's the kind of number that ends careers at performance-obsessed companies.

CSS-in-JS was a brilliant idea that solved real problems. It also introduced a new class of problems that the frontend community spent half a decade pretending weren't that serious. They were. React Server Components just made it impossible to ignore them any longer.

Let's do the post-mortem — and then talk about what comes next.


Part 1: Why CSS-in-JS Won (2017–2021)

To understand the death, you have to understand the life.

Before styled-components arrived in 2016, writing CSS in large React applications was genuinely painful. You had:

  • Global namespace collisions.button in one file overrides .button in another
  • Dead code — no tooling to know which CSS rules were actually used
  • Disconnected files — styles in one file, components in another, logic in a third
  • No dynamic styles — toggling themes or states required class gymnastics

styled-components, and then Emotion, solved all of this elegantly:

// Before: CSS gymnastics
<button className={`btn \({isActive ? 'btn--active' : ''} \){size === 'lg' ? 'btn--lg' : ''}`}>
  Click me
</button>

// After: Pure JavaScript logic
const Button = styled.button`
  background: ${({ isActive }) => isActive ? '#0070f3' : '#fff'};
  padding: ${({ size }) => size === 'lg' ? '12px 24px' : '8px 16px'};
  border-radius: 6px;
`;

The developer experience was transformative. Styles lived next to the component. Theming via ThemeProvider was elegant. TypeScript props made dynamic styles type-safe. The ecosystem exploded.

By 2021, styled-components had ~8 million weekly downloads. Emotion powered MUI (Material UI), Chakra UI, and dozens of major component libraries. CSS-in-JS wasn't just popular — it was the default assumption for serious React applications.

Then the cracks appeared.


Part 2: The Cracks (2021–2023)

The performance community had been raising alarms for years. The warnings were there — they were just easy to dismiss when your app "felt fast enough."

The Runtime Tax

Every CSS-in-JS library that injects styles at runtime follows the same basic loop on every render:

  1. Parse the template literal or style object
  2. Hash the result to generate a unique class name
  3. Check if that hash already exists in the style sheet
  4. If not, inject a new <style> tag into the <head>
  5. Apply the class name to the element

On a page with 200+ styled components, this adds 12–35ms of extra main-thread work per render cycle. That's enough to drop below the 60fps threshold on mid-range Android devices. On re-renders, each updated styled component adds another 3–8ms.

⚠️ Warning: These aren't hypothetical numbers. Performance profiling across 10 large-scale production applications showed CSS-in-JS consistently adding 12–35ms to initial render and 3–8ms per re-render. (Source: markaicode.com, 2025)

The Bundle Bloat

styled-components adds ~13.4KB gzipped to your JavaScript bundle. Emotion adds ~6.5KB. That's before a single line of your actual styles.

More critically, your styles ship as JavaScript strings that the browser must parse and execute — not as CSS that the browser's native CSS engine can process in parallel. JavaScript parsing is significantly slower than CSS parsing.

The SSR Hydration Nightmare

Server-side rendering with CSS-in-JS has always been fragile. The library must generate the same hashed class names on the server as it will on the client. Any divergence produces hydration mismatches — those cryptic Warning: Text content does not match errors in production.

Debugging them means tracing obfuscated class names like .css-4kq0lj through minified bundles. It's the kind of experience that makes developers question their career choices.


Part 3: The Killing Blow — React Server Components

In 2023, Next.js 13 shipped the App Router with React Server Components (RSC) as the default. This wasn't a minor architectural change — it was a paradigm shift. And it exposed a fundamental incompatibility with runtime CSS-in-JS.

The Next.js documentation put it plainly:

From the Next.js official docs: "CSS-in-JS libraries which require runtime JavaScript are not currently supported in Server Components. Using CSS-in-JS with newer React features like Server Components and Streaming requires library authors to support the latest version of React, including concurrent rendering."

Here's why this is a hard problem, not a configuration issue:

Runtime CSS-in-JS libraries work by:

  1. Using React Context to propagate theme data
  2. Using useInsertionEffect (or equivalent lifecycle hooks) to inject styles
  3. Maintaining a style registry that tracks what's been rendered

None of these mechanisms exist in Server Components. Server Components don't have lifecycle hooks. They don't have context. They run on the server, produce HTML, and that's it.

You can use styled-components in the App Router — but only in Client Components ('use client'). This means you lose the performance benefits of Server Components for any component that uses your styling library. In practice, this forces your entire component tree toward the client boundary, negating the primary reason to adopt the App Router in the first place.

App Router Architecture Reality with CSS-in-JS:

✅ Server Component (no styles)
  └── 'use client' StyledComponentsRegistry (required wrapper)
        └── 'use client' Layout (forced client)
              └── 'use client' Header (forced client)
                    └── 'use client' Button (forced client)

vs. what you actually want:

✅ Server Component
  └── ✅ Server Component Layout
        └── ✅ Server Component Header
              └── 'use client' Button (only interactive parts)

The styled-components team has made progress — React 19 now hoists and deduplicates <style> tags, enabling some RSC compatibility. But the ergonomics remain painful, hydration edge cases persist, and the fundamental runtime overhead doesn't go away.

The ecosystem made a collective decision: it's time to move on.


Part 4: Performance Benchmarks — The Numbers Don't Lie

Let's put concrete numbers to the abstract problem. Here's what real-world benchmarking shows:

Lighthouse Scores by Application Type

Application Type CSS Modules Score CSS-in-JS Score Performance Gap
E-commerce (500+ components) 92/100 84/100 -8 points
Enterprise Dashboard (1000+ components) 89/100 78/100 -11 points
Social Media App (300+ components) 94/100 87/100 -7 points

Load Time Comparison

Application CSS Modules CSS-in-JS Difference
Large E-commerce 2.1s initial load 2.8s initial load +33% slower
Enterprise Dashboard 1.8s initial load 2.5s initial load +39% slower
Social Media App 1.4s initial load 1.9s initial load +36% slower

Render Overhead Per Component

Metric CSS-in-JS CSS Modules Delta
Initial render overhead 12–35ms 0ms +12–35ms
Re-render overhead 3–8ms 0ms +3–8ms
Memory (heap) +2–5MB ~0MB +2–5MB
Paint/layout cycles +15–25% baseline +15–25%

Bundle Size Impact

styled-components:  ~13.4KB gzipped (JS runtime)
Emotion:            ~6.5KB gzipped (JS runtime)
Tailwind CSS v4:    0KB JS + 10-30KB CSS (build-time)
CSS Modules:        0KB JS overhead
vanilla-extract:    0KB JS runtime

🔥 Hot Take: A styled-components app with 500 components is paying a ~30KB JS tax on every page load plus 12-35ms of CPU time on every render. For users on 3G or low-end Android devices, this is the difference between a usable app and an abandoned tab.


Part 5: The Modern Alternatives — Ranked

The good news: the alternatives are genuinely excellent. Here's an honest assessment of what's replaced CSS-in-JS.

🏆 Tier 1: The Clear Winners

1. Tailwind CSS v4

~40M weekly downloads. The undisputed ecosystem leader.

Tailwind v4 (released early 2025) is a complete architectural rewrite. The PostCSS engine was replaced with a Rust-based Lightning CSS processor — builds are 5–10x faster, with cold builds completing in under 500ms for most projects.

<!-- Before: styled-components -->
<Button variant="primary" size="lg">Submit</Button>

<!-- After: Tailwind -->
<button class="bg-blue-600 hover:bg-blue-700 text-white font-medium px-6 py-3 rounded-lg transition-colors">
  Submit
</button>

Pros:

  • ✅ Zero runtime — pure CSS classes, works in RSC
  • ✅ Enormous ecosystem (shadcn/ui, daisyUI, Radix UI)
  • ✅ 5-minute setup
  • ✅ CSS-first config via @theme directives in v4
  • ✅ Production CSS: 10–30KB gzipped (only used utilities)

Cons:

  • ❌ No TypeScript type safety for class names (without tooling)
  • ❌ Long class strings can feel verbose
  • ❌ Style composition requires tailwind-merge to avoid conflicts

Best for: Most teams. If you don't have strong opinions about styling architecture, Tailwind is the pragmatic default.


2. CSS Modules

Built into Next.js, Vite, and every major bundler. Zero configuration.

CSS Modules are the unsexy, reliable choice that just works. Styles are scoped to the component at build time, there's zero runtime overhead, and they're fully RSC-compatible.

/* Button.module.css */
.button {
  background: #0070f3;
  color: white;
  padding: 8px 16px;
  border-radius: 6px;
}

.button:hover {
  background: #0051cc;
}
// Button.tsx
import styles from './Button.module.css';

export function Button({ children }) {
  return <button className={styles.button}>{children}</button>;
}

Pros:

  • ✅ Zero runtime, zero configuration
  • ✅ Full RSC compatibility
  • ✅ Standard CSS — no new API to learn
  • ✅ Excellent debugging (class names like Button_button__1bmv6)

Cons:

  • ❌ No TypeScript type safety
  • ❌ Dynamic styles require CSS custom properties or class toggling
  • ❌ No built-in theming system

Best for: Teams migrating from CSS-in-JS who want the lowest-friction path. Also excellent for component libraries.


🥈 Tier 2: The Power Tools

3. vanilla-extract

2.5M weekly downloads. TypeScript-first, zero-runtime CSS.

vanilla-extract lets you write styles in .css.ts files using TypeScript. The result is statically typed CSS with zero runtime overhead. Used by MUI v6 and Atlassian.

// button.css.ts
import { style, styleVariants } from '@vanilla-extract/css';

export const base = style({
  padding: '8px 16px',
  borderRadius: '6px',
  fontWeight: 500,
});

export const variants = styleVariants({
  primary: { background: '#0070f3', color: 'white' },
  secondary: { background: '#f4f4f5', color: '#18181b' },
});
// Button.tsx
import { base, variants } from './button.css';

export function Button({ variant = 'primary', children }) {
  return (
    <button className={`\({base} \){variants[variant]}`}>
      {children}
    </button>
  );
}

Pros:

  • ✅ Full TypeScript type safety — catch CSS errors at compile time
  • ✅ Zero runtime overhead
  • ✅ Full RSC compatibility
  • ✅ Excellent for design systems

Cons:

  • ❌ Separate .css.ts files (no colocation)
  • ❌ Medium learning curve
  • ❌ More verbose than Tailwind for simple cases

Best for: Design system authors and teams who need TypeScript guarantees for their CSS.


4. Panda CSS

~400K weekly downloads. Zero-runtime CSS with CSS-in-JS ergonomics.

Built by the Chakra UI team, Panda CSS bridges the gap between CSS-in-JS DX and zero-runtime performance. You write styles as JavaScript objects, and Panda generates atomic CSS at build time.

import { css } from '../styled-system/css';

function Button({ variant = 'primary' }) {
  return (
    <button
      className={css({
        bg: 'blue.600',
        color: 'white',
        px: 4,
        py: 2,
        rounded: 'md',
        _hover: { bg: 'blue.700' },
      })}
    >
      Click me
    </button>
  );
}

Pros:

  • ✅ CSS-in-JS-like DX with zero runtime cost
  • ✅ First-class design token system
  • ✅ Full TypeScript type safety
  • ✅ RSC compatible
  • ✅ Powers Chakra UI v3 and Park UI

Cons:

  • ❌ Complex setup (generates a styled-system folder)
  • ❌ Smaller ecosystem than Tailwind
  • ❌ Still maturing

Best for: Teams who loved the CSS-in-JS DX but need RSC compatibility and zero runtime.


5. StyleX (Meta)

~300K weekly downloads. Atomic CSS for large-scale applications.

StyleX is what Meta built to style Facebook.com and Instagram. It solves the CSS specificity problem at scale by guaranteeing that the last style applied always wins — no insertion order surprises.

import * as stylex from '@stylexjs/stylex';

const styles = stylex.create({
  button: {
    backgroundColor: '#0070f3',
    color: 'white',
    padding: '8px 16px',
  },
  active: {
    backgroundColor: '#0051cc',
  },
});

function Button({ isActive }) {
  return (
    <button {...stylex.props(styles.button, isActive && styles.active)}>
      Click me
    </button>
  );
}

Pros:

  • ✅ Guaranteed conflict-free style composition
  • ✅ Zero runtime (compiled to atomic CSS)
  • ✅ First-class TypeScript support
  • ✅ RSC compatible

Cons:

  • ❌ 30–60 minute setup
  • ❌ Smaller community, fewer resources
  • ❌ React-first (other framework adapters exist but are secondary)

Best for: Large engineering organizations where CSS conflicts are a real operational problem.


The Comparison Table

Solution Runtime Cost RSC Compatible TypeScript Safety Bundle (JS) Learning Curve
Tailwind v4 ✅ None ✅ Yes ⚠️ Basic 0 KB 🟢 Low
CSS Modules ✅ None ✅ Yes ❌ None 0 KB 🟢 Low
vanilla-extract ✅ None ✅ Yes ✅ Excellent 0 KB 🟡 Medium
Panda CSS ✅ None ✅ Yes ✅ Excellent ~1 KB 🟡 Medium
StyleX ✅ None ✅ Yes ✅ Excellent ~15 KB 🟡 Medium
styled-components ❌ High ⚠️ Client only ✅ Good ~13 KB 🟢 Low
Emotion ❌ Medium-High ⚠️ Client only ✅ Good ~7 KB 🟢 Low

Part 6: The Migration Guide

💡 Pro Tip: Don't do a big-bang rewrite. Migrate incrementally — one component or route at a time. Teams that have completed migrations report up to 45% reduction in JS bundle size and 30% faster Time-to-Interactive.

Here's a practical migration path from styled-components to CSS Modules (the lowest-friction path) or Tailwind (the most future-proof path).

Step 1: Audit Your Current Situation

Before touching code, measure what you have:

# Measure your current bundle
npx bundlephobia styled-components

# Run Lighthouse and record:
# - JS bundle size
# - First Contentful Paint (FCP)
# - Time to Interactive (TTI)
# - Lighthouse Performance Score

Set these as your baseline. You'll want to validate improvement at the end.

Step 2: Stop the Bleeding

Add a lint rule to prevent new styled-components usage in new files:

// .eslintrc.json
{
  "rules": {
    "no-restricted-imports": ["error", {
      "paths": [{
        "name": "styled-components",
        "message": "Use CSS Modules or Tailwind instead. See migration guide."
      }]
    }]
  }
}

Step 3: Migrate Component by Component

Before (styled-components):

import styled from 'styled-components';

const Card = styled.div`
  background: white;
  border-radius: 8px;
  padding: 24px;
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
`;

const CardTitle = styled.h2`
  font-size: 1.25rem;
  font-weight: 600;
  color: ${({ theme }) => theme.colors.text.primary};
  margin-bottom: 8px;
`;

const CardBody = styled.p`
  color: ${({ theme }) => theme.colors.text.secondary};
  line-height: 1.6;
`;

export function ProductCard({ title, description }) {
  return (
    <Card>
      <CardTitle>{title}</CardTitle>
      <CardBody>{description}</CardBody>
    </Card>
  );
}

After (CSS Modules):

/* ProductCard.module.css */
.card {
  background: white;
  border-radius: 8px;
  padding: 24px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.title {
  font-size: 1.25rem;
  font-weight: 600;
  color: var(--color-text-primary);
  margin-bottom: 8px;
}

.body {
  color: var(--color-text-secondary);
  line-height: 1.6;
}
// ProductCard.tsx
import styles from './ProductCard.module.css';

export function ProductCard({ title, description }) {
  return (
    <div className={styles.card}>
      <h2 className={styles.title}>{title}</h2>
      <p className={styles.body}>{description}</p>
    </div>
  );
}

After (Tailwind):

// ProductCard.tsx — no separate CSS file needed
export function ProductCard({ title, description }) {
  return (
    <div className="bg-white rounded-lg p-6 shadow-sm">
      <h2 className="text-xl font-semibold text-gray-900 mb-2">{title}</h2>
      <p className="text-gray-600 leading-relaxed">{description}</p>
    </div>
  );
}

Step 4: Handle Dynamic Styles

The trickiest part of migration is dynamic styles. CSS-in-JS made these trivial; the alternatives require a different mental model.

Before (styled-components):

const Badge = styled.span<{ variant: 'success' | 'error' | 'warning' }>`
  padding: 2px 8px;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 500;
  background: ${({ variant }) => ({
    success: '#dcfce7',
    error: '#fee2e2',
    warning: '#fef9c3',
  }[variant])};
  color: ${({ variant }) => ({
    success: '#166534',
    error: '#991b1b',
    warning: '#854d0e',
  }[variant])};
`;

After (Tailwind with CVA):

import { cva } from 'class-variance-authority';

const badge = cva('px-2 py-0.5 rounded-full text-xs font-medium', {
  variants: {
    variant: {
      success: 'bg-green-100 text-green-800',
      error: 'bg-red-100 text-red-800',
      warning: 'bg-yellow-100 text-yellow-800',
    },
  },
  defaultVariants: { variant: 'success' },
});

export function Badge({ variant, children }) {
  return <span className={badge({ variant })}>{children}</span>;
}

After (CSS Modules with data attributes):

/* Badge.module.css */
.badge {
  padding: 2px 8px;
  border-radius: 9999px;
  font-size: 0.75rem;
  font-weight: 500;
}

.badge[data-variant="success"] { background: #dcfce7; color: #166534; }
.badge[data-variant="error"] { background: #fee2e2; color: #991b1b; }
.badge[data-variant="warning"] { background: #fef9c3; color: #854d0e; }
export function Badge({ variant, children }) {
  return (
    <span className={styles.badge} data-variant={variant}>
      {children}
    </span>
  );
}

Step 5: Replace ThemeProvider with CSS Custom Properties

CSS custom properties (variables) are the native replacement for ThemeProvider. They work everywhere, including Server Components.

/* globals.css */
:root {
  --color-primary: #0070f3;
  --color-primary-hover: #0051cc;
  --color-text-primary: #18181b;
  --color-text-secondary: #71717a;
  --radius-md: 6px;
  --spacing-4: 16px;
}

[data-theme="dark"] {
  --color-text-primary: #fafafa;
  --color-text-secondary: #a1a1aa;
}
// No ThemeProvider needed — just toggle data-theme on <html>
function toggleTheme() {
  document.documentElement.dataset.theme =
    document.documentElement.dataset.theme === 'dark' ? '' : 'dark';
}

Step 6: Measure and Celebrate

After migrating your critical path components:

# Re-run your Lighthouse audit
# Compare against your baseline from Step 1

# Expected improvements:
# - JS bundle: -12 to -30KB (removing the library)
# - FCP: 15-30% faster
# - TTI: 20-45% faster
# - Lighthouse score: +7 to +11 points

📊 Key Concept: Don't migrate everything at once. Start with your highest-traffic pages and most-rendered components. Even a partial migration delivers measurable wins.


The Verdict

CSS-in-JS isn't going to zero. Emotion still powers MUI. styled-components still works for client-only apps. If you have a pre-App Router Next.js codebase that's stable and performant, there's no emergency.

But for any team:

  • Building a new Next.js App Router project
  • Adopting React Server Components
  • Experiencing performance issues on mobile
  • Evaluating their styling strategy for the next 3–5 years

The answer is clear: runtime CSS-in-JS is the wrong tool for 2025 and beyond.

The ecosystem has spoken. Tailwind has 40M weekly downloads. CSS Modules are built into every major framework. vanilla-extract, Panda CSS, and StyleX offer the DX of CSS-in-JS without the runtime tax.

This isn't a funeral — it's a graduation. CSS-in-JS taught us that styles and components belong together, that theming should be systematic, and that developer experience matters. The next generation of tools took those lessons and built something better.

Time to upgrade.


Further Reading


What Should You Do Right Now?

  1. Run a Lighthouse audit on your most important page. Record the JS bundle size and performance score.
  2. Identify your highest-traffic components — these are your migration targets.
  3. Pick your replacement — Tailwind for most teams, CSS Modules for the lowest friction, Panda CSS if you want to keep CSS-in-JS ergonomics.
  4. Add the ESLint rule to stop new styled-components usage today.
  5. Migrate one component this week. Measure the difference.

The data is unambiguous. The ecosystem has moved. The only question is when your team follows.


Found this useful? Share it with your team's frontend channel. Have a migration story of your own? The comments are open.