CSS-in-JS in 2025: Which Solution Fits Your Design System?

· 6 min read

I've shipped production apps with most of the major CSS-in-JS libraries, and honestly? The landscape has changed dramatically in the past couple years. Runtime CSS-in-JS took a beating from the React Server Components shift, while zero-runtime solutions are having their moment. Let me break down what actually matters when choosing between these tools.

The Runtime vs Zero-Runtime Split

This is the fundamental decision you're making. Runtime CSS-in-JS libraries like styled-components and Emotion parse your styles at runtime in the browser. Zero-runtime solutions like vanilla-extract and Linaria do all the work at build time.

The performance difference is real. I profiled a dashboard app last year that was using styled-components, and we were spending about 40ms on style injection during initial render on mid-range devices. After migrating to vanilla-extract, that dropped to basically zero because all the CSS was extracted at build time.

But here's the thing - runtime libraries give you something powerful: you can compute styles based on any JavaScript value without jumping through hoops. That flexibility comes at a cost though.

Styled Components: The Old Reliable

1import styled from 'styled-components';
2
3const Button = styled.button<{ $variant: 'primary' | 'secondary' }>`
4  padding: 12px 24px;
5  border-radius: 8px;
6  font-weight: 600;
7  transition: all 0.2s;
8  
9  ${props => props.$variant === 'primary' && `
10    background: #0066ff;
11    color: white;
12    
13    &:hover {
14      background: #0052cc;
15    }
16  `}
17  
18  ${props => props.$variant === 'secondary' && `
19    background: transparent;
20    color: #0066ff;
21    border: 2px solid #0066ff;
22    
23    &:hover {
24      background: rgba(0, 102, 255, 0.1);
25    }
26  `}
27`;
28
29// Usage
30<Button $variant="primary" onClick={handleClick}>
31  Save Changes
32</Button>

Styled-components still has the best DX for component-scoped styles. The template literal syntax feels natural, you get full TypeScript support, and the component model maps perfectly to how React developers think.

The downsides? Bundle size (around 16kb minified) and that runtime overhead. Also, if you're using React Server Components, you're going to have a bad time. Styled-components requires a client-side runtime, which means you need 'use client' directives everywhere.

I still reach for styled-components on client-heavy apps where the runtime cost is negligible compared to other JavaScript work. Think admin dashboards, design tools, anything that's already doing heavy client-side computation.

Emotion: More Flexible, Same Trade-offs

1import { css } from '@emotion/react';
2
3const buttonStyles = css`
4  padding: 12px 24px;
5  border-radius: 8px;
6  font-weight: 600;
7  transition: all 0.2s;
8`;
9
10const variantStyles = {
11  primary: css`
12    background: #0066ff;
13    color: white;
14    &:hover { background: #0052cc; }
15  `,
16  secondary: css`
17    background: transparent;
18    color: #0066ff;
19    border: 2px solid #0066ff;
20    &:hover { background: rgba(0, 102, 255, 0.1); }
21  `
22};
23
24function Button({ variant, children, ...props }) {
25  return (
26    <button css={[buttonStyles, variantStyles[variant]]} {...props}>
27      {children}
28    </button>
29  );
30}

Emotion gives you more control over composition. I prefer its object styles API for design systems because you can share style objects more easily between components. The css prop is also more explicit than styled-components' magic.

Performance-wise, they're roughly equivalent. Emotion is slightly smaller (around 11kb), but you're still paying that runtime cost. The real difference is in how you structure your code.

Vanilla Extract: Zero Runtime, Maximum Type Safety

This is where things get interesting. Vanilla-extract generates static CSS files at build time but gives you TypeScript for your styles.

1// button.css.ts
2import { style, styleVariants } from '@vanilla-extract/css';
3
4export const base = style({
5  padding: '12px 24px',
6  borderRadius: 8,
7  fontWeight: 600,
8  transition: 'all 0.2s',
9});
10
11export const variant = styleVariants({
12  primary: {
13    background: '#0066ff',
14    color: 'white',
15    ':hover': {
16      background: '#0052cc',
17    },
18  },
19  secondary: {
20    background: 'transparent',
21    color: '#0066ff',
22    border: '2px solid #0066ff',
23    ':hover': {
24      background: 'rgba(0, 102, 255, 0.1)',
25    },
26  },
27});
28
29// Button.tsx
30import * as styles from './button.css';
31
32function Button({ variant, children, ...props }) {
33  return (
34    <button className={`${styles.base} ${styles.variant[variant]}`} {...props}>
35      {children}
36    </button>
37  );
38}

The constraint is real: you can only use static values in your .css.ts files. No props, no hooks, no runtime JavaScript values. At first this feels limiting, but it forces you to think about your design system more systematically.

I've found vanilla-extract works best when you embrace design tokens and variants. Define your colors, spacing, and typography scales upfront, then compose them. The TypeScript integration is legitimately great - you get autocomplete for your design tokens and catch typos at build time.

The catch? Your build gets more complex. You need to configure your bundler to handle .css.ts files, and build times increase slightly. For a medium-sized app, we're talking an extra 2-3 seconds. Worth it for the runtime performance gains.

Panda CSS: Utility-First with Type Safety

Panda is the new kid trying to bridge the gap between Tailwind and CSS-in-JS. It generates atomic CSS at build time but with a component-friendly API.

1import { css } from '../styled-system/css';
2import { stack } from '../styled-system/patterns';
3
4function Button({ variant, children, ...props }) {
5  return (
6    <button
7      className={css({
8        px: '6',
9        py: '3',
10        borderRadius: 'lg',
11        fontWeight: 'semibold',
12        transition: 'all',
13        transitionDuration: 'fast',
14        bg: variant === 'primary' ? 'blue.600' : 'transparent',
15        color: variant === 'primary' ? 'white' : 'blue.600',
16        border: variant === 'secondary' ? '2px solid' : 'none',
17        borderColor: variant === 'secondary' ? 'blue.600' : undefined,
18        _hover: {
19          bg: variant === 'primary' ? 'blue.700' : 'blue.50',
20        },
21      })}
22      {...props}
23    >
24      {children}
25    </button>
26  );
27}

Panda generates utility classes like Tailwind but you write them in JavaScript. You get full TypeScript support for your design tokens, and it's zero-runtime. The generated CSS is atomic, so you get good caching characteristics.

I'm still evaluating Panda for production use. The DX is solid, but the ecosystem is young. Documentation gaps exist, and you'll be on your own for some edge cases. If you like Tailwind but want better TypeScript integration and more component-friendly APIs, check it out.

The RSC Problem

React Server Components changed the game. Runtime CSS-in-JS libraries fundamentally don't work in server components because they need client-side JavaScript to inject styles. You can work around this by marking everything 'use client', but then you're losing the benefits of RSC.

Zero-runtime solutions work fine because they generate static CSS that can be included in your initial HTML. This is why Next.js 13+ docs explicitly recommend against runtime CSS-in-JS.

I migrated a Next.js app from styled-components to vanilla-extract specifically because of RSC. The migration took about two weeks for a medium-sized codebase, but the performance improvements were noticeable - faster initial page loads and better Lighthouse scores.

What I Actually Use

For new projects in 2024, I default to vanilla-extract. The type safety and zero-runtime performance are worth the slight DX hit. I keep my design tokens in a separate package and generate variants systematically.

For existing styled-components codebases, I don't rush to migrate unless we're adopting RSC. The runtime overhead is manageable for most apps, and migration costs are high.

For quick prototypes or client-heavy apps where RSC doesn't matter, Emotion's css prop is still my go-to. It's fast to write and flexible enough for experimentation.

Panda is on my watch list. If the ecosystem matures and more teams adopt it, the utility-first approach with full type safety could be compelling.

The real answer? It depends on your constraints. RSC adoption, team preferences, performance requirements, and existing infrastructure all matter. Just avoid the trap of thinking there's one "best" solution - there isn't.

Share this page

Article by Emily Nakamura

Frontend architect and design systems specialist. Advocates for accessible, user-centric React applications. Former lead engineer at a major design tool company.