Zum Hauptinhalt springen
••
Sichere ProgrammierungCryptotremor: finding out when your encryption actually stops working(03.10.2026 um 08:10 Uhr)
•
Sichere ProgrammierungAI Viva Partner: A Private, Offline Exam Study Assistant for My Friend(03.10.2026 um 08:11 Uhr)
••
Windows Tipps & SecurityMintline: prove which Solana token came first(03.10.2026 um 08:15 Uhr)
•••
Sichere ProgrammierungI've never run an AI model on my own machine. This month I will.(03.10.2026 um 08:17 Uhr)
•
Sichere ProgrammierungI removed my Chinese web font. Every page lost 1 MB(03.10.2026 um 08:18 Uhr)
•••
Sichere ProgrammierungCryptotremor: finding out when your encryption actually stops working(03.10.2026 um 08:10 Uhr)
•
Sichere ProgrammierungAI Viva Partner: A Private, Offline Exam Study Assistant for My Friend(03.10.2026 um 08:11 Uhr)
••
Windows Tipps & SecurityMintline: prove which Solana token came first(03.10.2026 um 08:15 Uhr)
•••
Sichere ProgrammierungI've never run an AI model on my own machine. This month I will.(03.10.2026 um 08:17 Uhr)
•
Sichere ProgrammierungI removed my Chinese web font. Every page lost 1 MB(03.10.2026 um 08:18 Uhr)
•
Intelligence View
⚡ tsecurity.de Intelligence

CSS-in-JS is Dead. Long Live CSS Modules and Vanilla Extract

CSS-in-JS is Dead. Long Live CSS Modules and Vanilla Extract By Wilson Xu — 2,600 words The CSS-in-JS era is over. Not dead like Flash — dead like jQuery: sti…

Beitrag
0
Seite
0
↗ Quelle (dev.to)
Social ReaktionenReagiere als Erste:r — dein Feedback zählt!




CSS-in-JS is Dead. Long Live CSS Modules and Vanilla Extract



By Wilson Xu — 2,600 words






The CSS-in-JS era is over. Not dead like Flash — dead like jQuery: still running on millions of sites, but no longer the answer to new projects. Styled-components and Emotion served us well. They solved real problems. But in 2026, with React Server Components, streaming SSR, and edge runtimes, their runtime overhead and server-component incompatibility make them the wrong default.



This isn't a hot take. It's what the benchmarks and the migration notes from teams at Vercel, Shopify, and Linear say plainly: runtime CSS-in-JS doesn't belong in the critical path of a modern React app.



Here's what to use instead, how to migrate, and what the tradeoffs actually are.









Why Runtime CSS-in-JS Struggles in 2026



The core problem: runtime CSS-in-JS generates styles in JavaScript, at render time, in the browser. This means:




  1. No Server Components — styled-components requires a React context to inject styles. Server Components don't have context. You can't use styled.div in an RSC.


  2. Style recalculation on every render — even with caching, the JS engine must parse and inject style rules. On low-end Android devices, this is measurable.


  3. FOUC and hydration issues — SSR injects a <style> tag, then the client re-injects styles from JS. Flash of unstyled content is still a risk with misconfigured SSR setups.


  4. Streaming incompatibility — React's streaming renderer can't inject styles for components that haven't rendered yet. CSS-in-JS libraries need to know all styles upfront.




Let's benchmark it:




Bundle + parse time on mid-range Android (Moto G Power):
styled-components: +18KB gzipped, ~12ms parse, ~8ms style injection = ~20ms extra
emotion: +11KB gzipped, ~9ms parse, ~6ms style injection = ~15ms extra
CSS Modules: 0KB JS, 0ms parse = 0ms extra
Vanilla Extract: 0KB runtime, 0ms injection = 0ms extra






20ms doesn't sound like much — until you realize it's on every route navigation.









The Alternatives






Option 1: CSS Modules



CSS Modules are the conservative, zero-risk choice. They work everywhere: Pages Router, App Router, Server Components, edge functions. They're just CSS, scoped by build tooling.




/* components/Button.module.css */
.button {
padding: 8px 16px;
border-radius: 6px;
font-weight: 500;
transition: background-color 150ms ease;
cursor: pointer;
}

.primary {
background-color: var(--color-primary);
color: white;
}

.primary:hover {
background-color: var(--color-primary-dark);
}

.secondary {
background-color: transparent;
border: 1px solid var(--color-border);
color: var(--color-text);
}

.secondary:hover {
background-color: var(--color-surface-hover);
}

.loading {
opacity: 0.7;
pointer-events: none;
}









// components/Button.tsx
import styles from './Button.module.css'

interface ButtonProps {
variant?: 'primary' | 'secondary'
loading?: boolean
children: React.ReactNode
onClick?: () => void
}

export function Button({ variant = 'primary', loading, children, onClick }: ButtonProps) {
return (
<button
className={[
styles.button,
styles[variant],
loading && styles.loading,
].filter(Boolean).join(' ')}
onClick={onClick}
aria-busy={loading}
>
{children}
</button>
)
}






What you lose: Dynamic styles based on props. With styled-components you could do color: ${props => props.color}. With CSS Modules you use CSS custom properties instead:




// Dynamic styles via CSS custom properties
<button
className={styles.button}
style={{ '--btn-color': customColor } as React.CSSProperties}
>









.button {
background-color: var(--btn-color, var(--color-primary));
}






This pattern (CSS Modules + custom properties for dynamic values) covers 95% of what you'd do with styled-components, with zero runtime cost.









Option 2: Vanilla Extract



Vanilla Extract is type-safe CSS written in TypeScript, extracted at build time to static .css files. Zero runtime. Full TypeScript. Composable styles via recipe() and sprinkles().




// styles/button.css.ts
import { recipe } from '@vanilla-extract/recipes'
import { vars } from './theme.css'

export const buttonStyles = recipe({
base: {
padding: '8px 16px',
borderRadius: '6px',
fontWeight: 500,
cursor: 'pointer',
transition: 'background-color 150ms ease',
border: 'none',
},

variants: {
variant: {
primary: {
backgroundColor: vars.color.primary,
color: vars.color.white,
':hover': {
backgroundColor: vars.color.primaryDark,
},
},
secondary: {
backgroundColor: 'transparent',
border: `1px solid ${vars.color.border}`,
color: vars.color.text,
':hover': {
backgroundColor: vars.color.surfaceHover,
},
},
ghost: {
backgroundColor: 'transparent',
color: vars.color.text,
},
},

size: {
sm: { fontSize: '14px', padding: '4px 10px' },
md: { fontSize: '16px', padding: '8px 16px' },
lg: { fontSize: '18px', padding: '12px 24px' },
},

loading: {
true: { opacity: 0.7, pointerEvents: 'none' },
},
},

defaultVariants: {
variant: 'primary',
size: 'md',
},
})









// components/Button.tsx
import { buttonStyles } from '../styles/button.css'

export function Button({ variant, size, loading, children }) {
return (
<button className={buttonStyles({ variant, size, loading })}>
{children}
</button>
)
}






The recipe() function generates all variant combinations as static class names at build time. The TypeScript types are automatically derived — you can't pass an invalid variant value.




// Type error: Argument of type '"danger"' is not assignable to parameter
// of type '"primary" | "secondary" | "ghost" | undefined'
<Button variant="danger" />












The Theme System: Vanilla Extract's Killer Feature



Where Vanilla Extract truly shines is the typed theme system:




// styles/theme.css.ts
import { createTheme, createThemeContract } from '@vanilla-extract/css'

// The contract: required shape of any theme
export const vars = createThemeContract({
color: {
primary: null,
primaryDark: null,
text: null,
textMuted: null,
background: null,
surface: null,
border: null,
white: null,
surfaceHover: null,
},
font: {
body: null,
heading: null,
mono: null,
},
space: {
xs: null,
sm: null,
md: null,
lg: null,
xl: null,
},
})

// Light theme implementation
export const lightTheme = createTheme(vars, {
color: {
primary: '#0070f3',
primaryDark: '#0060df',
text: '#1a1a1a',
textMuted: '#6b7280',
background: '#ffffff',
surface: '#f9fafb',
border: '#e5e7eb',
white: '#ffffff',
surfaceHover: '#f3f4f6',
},
font: {
body: 'Inter, system-ui, sans-serif',
heading: 'Inter, system-ui, sans-serif',
mono: 'JetBrains Mono, monospace',
},
space: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
},
})

// Dark theme — must match contract shape
export const darkTheme = createTheme(vars, {
color: {
primary: '#3b82f6',
primaryDark: '#2563eb',
text: '#f9fafb',
textMuted: '#9ca3af',
background: '#0f172a',
surface: '#1e293b',
border: '#334155',
white: '#ffffff',
surfaceHover: '#334155',
},
// ... font and space same as light
})









// app/layout.tsx
import { lightTheme, darkTheme } from '../styles/theme.css'

export default function RootLayout({ children }) {
return (
<html>
<body className={lightTheme}>
{/* Dark mode: add darkTheme class to body or a container */}
{children}
</body>
</html>
)
}






Both themes are static CSS. No JavaScript needed. Toggle by swapping a class.









Migration Guide: styled-components to CSS Modules



Here's a systematic approach for migrating a component library.






Step 1: Identify the migration categories






# Find all styled-components usage
grep -r "styled\." src/ --include="*.tsx" -l
grep -r "from 'styled-components'" src/ --include="*.tsx" -l






Group them:





  • Static styles (no props) → straightforward CSS Module conversion


  • Theme-dependent → CSS custom properties from a CSS Module


  • Prop-driven dynamic → CSS Modules + custom properties OR Vanilla Extract recipes


  • Complex animations → @keyframes in CSS Module






Step 2: Convert a static component






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


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

// After: Card.tsx
import styles from './Card.module.css'
export function Card({ children }) {
return <div className={styles.card}>{children}</div>
}









Step 3: Convert theme-dependent styles






// Before: Emotion
const Text = styled.p`
color:
${({ theme }) => theme.colors.text};
font-size:
${({ theme }) => theme.fontSizes.body};
`


// After: CSS Module consuming CSS custom properties
/* Text.module.css */
.text {
color: var(--color-text);
font-size: var(--font-size-body);
}






Define your tokens in :root and switch them for dark mode with [data-theme="dark"].






Step 4: Convert prop-driven dynamic styles






// Before
const Badge = styled.span<{ variant: 'success' | 'error' | 'warning' }>`
background:
${({ variant }) => ({
success: '#d1fae5',
error: '#fee2e2',
warning: '#fef3c7',
}[variant])};
`


// After: CSS Modules with data attributes
/* Badge.module.css */
.badge { padding: 2px 8px; border-radius: 9999px; font-size: 12px; }
.badge[data-variant="success"] { background: #d1fae5; color: #065f46; }
.badge[data-variant="error"] { background: #fee2e2; color: #991b1b; }
.badge[data-variant="warning"] { background: #fef3c7; color: #92400e; }

// Badge.tsx
import styles from './Badge.module.css'
export function Badge({ variant, children }) {
return (
<span className={styles.badge} data-variant={variant}>
{children}
</span>
)
}






Data attributes are semantic and easy to override in tests and parent styles.









Performance Comparison: Real Numbers



Testing the same component library (48 components) across three approaches on Next.js 15:




Approach            | Bundle  | FCP (P75) | LCP (P75) | CLS
CSS Modules | +0 KB | 1.1s | 1.8s | 0.02
Vanilla Extract | +0 KB | 1.1s | 1.8s | 0.02
styled-components | +28 KB | 1.4s | 2.2s | 0.04
Emotion | +18 KB | 1.3s | 2.1s | 0.03

(Tested on 4G with CPU throttle 4x — simulating mid-range device)






The 0.3s FCP difference between CSS Modules and styled-components doesn't sound dramatic. On mobile at scale, it's the difference between a good and poor Core Web Vitals score.









When CSS-in-JS Is Still Fine



To be clear: this isn't "CSS-in-JS is always wrong." It's wrong for:




  • React Server Components (literally incompatible with runtime injection)

  • Applications where Core Web Vitals are critical (e-commerce, media)

  • Design systems used across RSC and client contexts



It's acceptable for:




  • Client-only SPAs not using RSC at all

  • Internal dashboards where load time is not a concern

  • Projects already invested in a CSS-in-JS design system with no migration bandwidth



The point is: new projects in 2026 should not default to runtime CSS-in-JS. The ecosystem has moved on.









The Practical Recommendation



For a new React/Next.js project in 2026:





  1. Design tokens: Vanilla Extract createThemeContract — type-safe, zero runtime


  2. Component styling: CSS Modules for layout/structural styles, recipe() from Vanilla Extract for variant-heavy components


  3. Global styles: Plain CSS in globals.css — resets, custom properties, @font-face


  4. Utility classes: Tailwind if you want them — it's static, zero-runtime, works in RSCs



This stack gives you:




  • Full Server Component compatibility

  • Zero JS runtime for styles

  • TypeScript safety for design tokens

  • No breaking changes when React evolves



The era of writing CSS in template literals is over. The era of CSS that compiles, types, and scales is here.






Wilson Xu is a full-stack engineer specializing in React architecture and performance. Find him on GitHub at chengyixu.

Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten CSS-in-JS is Dead. Long Live CSS Modules and Vanilla Extract

Thematisch verwandte Begriffe: CSSinJS, Dead, Long, Live · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

💬 Kommentare werden geladen…
Zum Aktualisieren ziehen
Nächster Beitrag