CSS-in-JS Solutions: Pros and Cons

·15 min read·Web Developmentintermediate

Why component styles matter in today's complex frontends

A developer workstation screen showing a React component with styled-components code alongside a preview of a themed button UI in a browser

When I first moved from managing global stylesheets to building components, the separation felt clean at first, but it broke down fast. A button component needed to work in a dashboard, a modal, and a marketing page, each with slightly different spacing, color, and state behavior. Copy-paste utility classes crept in. Naming collisions multiplied. The styles file grew into a maze. CSS-in-JS emerged as a pragmatic response to this reality: co-locate styles with components, make styles composable, and let the runtime handle dynamic values. That promise is still compelling, but it comes with tradeoffs that matter more on some teams and projects than others.

In this article, I’ll walk through what CSS-in-JS really looks like in practice, where it shines, where it hurts, and how to decide if it fits your project. We’ll look at real-world patterns, code you can run, and the architectural decisions that make a difference day to day.

Context: Where CSS-in-JS fits today

CSS-in-JS sits at the intersection of component architecture and design systems. It’s widely used in React codebases, especially in products where UI needs to be dynamic and themeable. Teams building component libraries, multi-tenant apps, or design systems often reach for CSS-in-JS to enforce boundaries, share tokens, and avoid global scope issues. It’s not limited to React; ecosystems like Emotion and Styled Components dominate that world, but libraries like Linaria, Stitches, and vanilla-extract offer zero-runtime approaches that compile styles at build time. On platforms like React Native, style objects are already inline by necessity, so CSS-in-JS is a different conversation.

Compared to utility-first CSS (Tailwind, for example) or plain CSS modules, CSS-in-JS leans into co-location and dynamic styling. With utility classes, you compose in markup and keep styles in utility layers. With CSS modules, you write traditional CSS and import scoped classes. With CSS-in-JS, you write styles attached to components, often with props-driven logic. Utility CSS favors small, predictable bundles and compile-time optimization. CSS modules favor traditional CSS authoring with better isolation. CSS-in-JS favors runtime flexibility and design system integration.

Who uses it today? Frontend teams managing component libraries and theming, product squads building features that span many contexts, and engineers who want to reduce naming churn. If your app is small, the overhead may not be worth it. If your app is large with dynamic theming, variant-heavy components, and shared libraries, the benefits tend to compound.

Core concepts: How CSS-in-JS works in practice

At the core, CSS-in-JS libraries generate and scope CSS classes for your components. Some do this at runtime in the browser, injecting style tags as components render. Others do it at build time, extracting CSS to static files to avoid runtime cost. You can think of CSS-in-JS as “styles as data”: styles are functions of props, and the library turns that into real CSS.

Runtime libraries like Styled Components and Emotion are popular because they feel native to React. You define a styled component and pass props that influence styles. They manage vendor prefixes, media queries, and dynamic injection.

Zero-runtime libraries like Linaria and vanilla-extract shift the work to build time. They compile your style definitions to static CSS and emit minimal or no runtime code. This is great for performance, especially on slower networks or devices. The tradeoff is build complexity and sometimes a less flexible runtime API.

Below is a small but realistic example using Styled Components. It shows a Button that adapts to theme tokens and variant props. This pattern is common in design systems.

// src/theme.js
export const tokens = {
  colors: {
    primary: '#4f46e5',
    secondary: '#16a34a',
    danger: '#dc2626',
    text: '#111827',
    background: '#ffffff',
  },
  spacing: {
    xs: '4px',
    sm: '8px',
    md: '12px',
    lg: '16px',
  },
  radii: {
    sm: '6px',
    md: '8px',
    lg: '12px',
  },
};

export const lightTheme = {
  mode: 'light',
  ...tokens,
};

export const darkTheme = {
  mode: 'dark',
  colors: {
    ...tokens.colors,
    text: '#f9fafb',
    background: '#111827',
  },
  spacing: tokens.spacing,
  radii: tokens.radii,
};
// src/Button.jsx
import React from 'react';
import styled, { css } from 'styled-components';
import { useTheme } from './ThemeContext'; // Assumes a context providing theme

const variants = {
  primary: css`
    background: ${p => p.theme.colors.primary};
    color: white;
    border: none;
  `,
  secondary: css`
    background: ${p => p.theme.colors.secondary};
    color: white;
    border: none;
  `,
  danger: css`
    background: ${p => p.theme.colors.danger};
    color: white;
    border: none;
  `,
  outline: css`
    background: transparent;
    color: ${p => p.theme.colors.text};
    border: 1px solid ${p => p.theme.colors.text};
  `,
};

const StyledButton = styled.button`
  font-family: inherit;
  font-weight: 600;
  padding: ${p => p.theme.spacing.md} ${p => p.theme.spacing.lg};
  border-radius: ${p => p.theme.radii.md};
  cursor: pointer;
  transition: transform 0.05s ease, opacity 0.2s ease;
  opacity: ${p => (p.disabled ? 0.6 : 1)};
  pointer-events: ${p => (p.disabled ? 'none' : 'auto')};
  ${p => variants[p.variant || 'primary']}

  &:hover {
    transform: translateY(-1px);
  }

  &:active {
    transform: translateY(0);
  }

  ${p =>
    p.block &&
    css`
      display: block;
      width: 100%;
    `}
`;

export function Button({ children, variant, block, disabled, onClick }) {
  const theme = useTheme();
  return (
    <StyledButton
      theme={theme}
      variant={variant}
      block={block}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </StyledButton>
  );
}

This pattern is powerful because:

  • Styles are bound to the component, making reuse obvious.
  • Theme tokens flow through props automatically.
  • Variants are explicit and easy to test.

However, if you’re not careful, you can overuse dynamic styles and create too many unique class names, which bloats the CSS injected at runtime in libraries like Styled Components or Emotion. That’s where zero-runtime libraries differ: they can inline or extract styles deterministically.

A zero-runtime alternative with vanilla-extract looks like this:

// src/Button.css.ts (vanilla-extract)
import { style, recipe } from '@vanilla-extract/recipes';
import { vars } from './themes.css'; // Design tokens defined elsewhere

export const button = recipe({
  base: {
    fontFamily: 'inherit',
    fontWeight: 600,
    borderRadius: vars.radii.md,
    cursor: 'pointer',
    transition: 'transform 0.05s ease, opacity 0.2s ease',
    selectors: {
      '&:hover': { transform: 'translateY(-1px)' },
      '&:active': { transform: 'translateY(0)' },
    },
  },
  variants: {
    variant: {
      primary: {
        background: vars.colors.primary,
        color: 'white',
        border: 'none',
      },
      secondary: {
        background: vars.colors.secondary,
        color: 'white',
        border: 'none',
      },
      danger: {
        background: vars.colors.danger,
        color: 'white',
        border: 'none',
      },
      outline: {
        background: 'transparent',
        color: vars.colors.text,
        border: `1px solid ${vars.colors.text}`,
      },
    },
    block: {
      true: {
        display: 'block',
        width: '100%',
      },
    },
    disabled: {
      true: {
        opacity: 0.6,
        pointerEvents: 'none',
      },
    },
  },
  compoundVariants: [],
  defaultVariants: {
    variant: 'primary',
    block: false,
    disabled: false,
  },
});
// src/Button.jsx
import * as styles from './Button.css';
export function Button({ children, variant = 'primary', block, disabled, onClick }) {
  return (
    <button
      className={styles.button({ variant, block, disabled })}
      disabled={disabled}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

This approach compiles to static CSS at build time, so you avoid runtime injection and reduce the runtime bundle. It’s a good fit if you care about performance, SSR consistency, and predictable caching. The tradeoff is that it requires a build pipeline with vanilla-extract’s compiler and may not support some of the dynamic runtime features that Emotion or Styled Components offer out of the box.

Real-world patterns: Design tokens, theming, and responsive styles

Design tokens are the backbone of scalable UI. CSS-in-JS shines when tokens live in JavaScript and flow through components. Below is a minimal token setup that works with runtime and zero-runtime approaches.

// src/tokens.js
export const tokens = {
  colors: {
    brand: '#6d28d9',
    accent: '#f59e0b',
    text: '#1f2937',
    background: '#ffffff',
    surface: '#f3f4f6',
  },
  spacing: [4, 8, 12, 16, 24, 32].map(v => `${v}px`),
  radii: { sm: '6px', md: '8px', lg: '12px' },
  shadows: {
    sm: '0 1px 2px rgba(0,0,0,0.05)',
    md: '0 4px 10px rgba(0,0,0,0.08)',
  },
};

A common pattern is to wrap your app in a ThemeProvider and consume tokens via context or a global object. Theming can be toggled at runtime for dark mode. In Emotion, this is straightforward.

// src/ThemeContext.jsx
import React, { createContext, useContext, useState } from 'react';
import { ThemeProvider as EmotionProvider } from '@emotion/react';
import { lightTheme, darkTheme } from './theme';

const ThemeToggleContext = createContext();

export function useTheme() {
  return useContext(ThemeToggleContext).theme;
}

export function useToggleTheme() {
  return useContext(ThemeToggleContext).toggle;
}

export function ThemeProvider({ children }) {
  const [mode, setMode] = useState('light');
  const theme = mode === 'light' ? lightTheme : darkTheme;

  function toggle() {
    setMode(m => (m === 'light' ? 'dark' : 'light'));
  }

  return (
    <ThemeToggleContext.Provider value={{ theme, toggle }}>
      <EmotionProvider theme={theme}>{children}</EmotionProvider>
    </ThemeToggleContext.Provider>
  );
}

Responsive styles are another common case. In runtime libraries, you can use media queries directly. With zero-runtime libraries, you often define breakpoints in tokens and generate media queries at build time.

// src/ResponsiveCard.jsx (Emotion)
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useTheme } from './ThemeContext';

const cardStyles = p => css`
  background: ${p.theme.colors.surface};
  border-radius: ${p.theme.radii.md};
  box-shadow: ${p.theme.shadows.sm};
  padding: ${p.theme.spacing[2]};
  @media (min-width: 768px) {
    padding: ${p.theme.spacing[3]};
    box-shadow: ${p.theme.shadows.md};
  }
`;

const Card = styled.div`
  ${cardStyles}
`;

export function ResponsiveCard({ children }) {
  const theme = useTheme();
  return <Card theme={theme}>{children}</Card>;
}

In production systems, I’ve seen teams combine CSS-in-JS with a utility layer. They use styled components for structured, reusable components and utilities for one-off layout tweaks. This hybrid reduces the risk of style fragmentation while keeping dynamic styles ergonomic.

Evaluation: Pros, cons, and tradeoffs

Pros:

  • Scoped styles eliminate naming collisions and global leakage.
  • Dynamic styling based on props is natural and testable.
  • Theme integration is straightforward with context or providers.
  • Co-location reduces mental overhead when refactoring components.
  • Strong ecosystem for design systems and component libraries.

Cons:

  • Runtime libraries add overhead: style injection, class generation, and larger bundles in some cases.
  • SSR can be tricky if not configured properly, leading to flicker or style mismatch.
  • DevTools can become noisy with many auto-generated class names.
  • Build-time libraries add configuration complexity and may not support all dynamic patterns.
  • Performance impact can be noticeable on low-end devices if you rely on heavy runtime computation.

Tradeoffs:

  • Choose runtime CSS-in-JS (Styled Components, Emotion) when you need dynamic theming, server-side rendering with quick iteration, and a rich API for prop-driven styles. It’s best when your team values developer ergonomics and rapid iteration over minimal runtime cost.
  • Choose zero-runtime CSS-in-JS (Linaria, vanilla-extract) when performance, caching, and predictable CSS output are paramount. This fits performance-critical pages, large design systems, and teams that want static CSS extraction.
  • If your app is largely static or content-driven, utility-first CSS or CSS modules might be lighter and more maintainable.

A practical decision framework I use:

  • Do you need dynamic theming at runtime? Yes -> lean runtime or a hybrid.
  • Is SSR performance a top concern? Yes -> zero-runtime or careful SSR setup with runtime libs.
  • Are you building a shared component library? Yes -> choose the pattern your consumers expect (often runtime for simplicity, zero-runtime for performance).
  • Do you have strict bundle size targets? Yes -> prefer zero-runtime or CSS modules.

Personal experience: Lessons from production

On a medium-sized React app (40+ screens, shared component library), we adopted Emotion to support theming and component variants. The wins were real: fewer naming collisions, faster feature development, and cleaner component APIs. We built a set of “primitives” (Button, Card, Input, Modal) with variant props and token-driven styles. Refactoring felt safer because styles traveled with components.

But we also hit rough edges:

  • SSR mismatch flicker appeared when we forgot to configure the Emotion cache for SSR. The fix was straightforward but easy to miss: properly extract styles on the server and inject them in the HTML head. The Emotion SSR API docs were our reference (https://emotion.sh/docs/ssr).
  • Bundle size grew because we overused dynamic styles that created unique class names per render path. We audited and replaced some inline dynamic expressions with static variants, which reduced runtime CSS generation.
  • DevTools got noisy. We added Babel plugins for production minification and labeling, which helped map classes back to components in development.

On a newer project, we tried vanilla-extract for a performance-critical marketing site. The build-time compilation eliminated runtime style injection, and we shipped smaller CSS. However, some dynamic patterns (like style changes based on user input mid-render) were trickier. We ended up keeping a small runtime layer for those specific interactions, blending both approaches.

Getting started: Setup and workflow

You don’t need a massive overhaul to try CSS-in-JS. The mental model is simple: treat styles as co-located, composable code. For a runtime library like Emotion, you install the packages, set up a theme context, and start writing styled components. For zero-runtime, set up the compiler and define styles in .css.ts files.

Below is a sample project structure for a runtime approach using Emotion.

src/
  components/
    Button/
      index.jsx
      styles.js
    Card/
      index.jsx
  theme/
    tokens.js
    ThemeContext.jsx
  pages/
    Home.jsx
  App.jsx
  index.js
public/
  index.html

For a zero-runtime setup with vanilla-extract, you might structure it like this.

src/
  components/
    Button/
      Button.jsx
      Button.css.ts
  theme/
    themes.css.ts  // tokens as CSS variables
  pages/
    Home.jsx
  App.jsx
  index.js
public/
  index.html

A minimal Emotion setup looks like this:

// src/theme/ThemeContext.jsx
import React, { createContext, useContext, useState } from 'react';
import { ThemeProvider as EmotionProvider } from '@emotion/react';
import { lightTheme, darkTheme } from './tokens';

const ThemeCtx = createContext();

export const useTheme = () => useContext(ThemeCtx).theme;
export const useToggleTheme = () => useContext(ThemeCtx).toggle;

export function ThemeProvider({ children }) {
  const [mode, setMode] = useState('light');
  const toggle = () => setMode(m => (m === 'light' ? 'dark' : 'light'));
  const theme = mode === 'light' ? lightTheme : darkTheme;
  return (
    <ThemeCtx.Provider value={{ theme, toggle }}>
      <EmotionProvider theme={theme}>{children}</EmotionProvider>
    </ThemeCtx.Provider>
  );
}
// src/App.jsx
import { ThemeProvider } from './theme/ThemeContext';
import Home from './pages/Home';

export default function App() {
  return (
    <ThemeProvider>
      <Home />
    </ThemeProvider>
  );
}

To support SSR with Emotion, you’ll need to collect styles on the server and inject them. In a Node server using Express and React SSR, it looks like this:

// server/render.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import createCache from '@emotion/cache';
import { CacheProvider } from '@emotion/react';
import { extractCritical } from '@emotion/server';
import App from '../src/App';

export function render(req, res) {
  const cache = createCache({ key: 'css' });
  const html = renderToString(
    <CacheProvider value={cache}>
      <App />
    </CacheProvider>
  );
  const { css, ids } = extractCritical(html);

  res.send(`
    <!doctype html>
    <html>
      <head>
        <meta charset="utf-8" />
        <title>SSR App</title>
        <style data-emotion="${cache.key} ${ids.join(' ')}">${css}</style>
      </head>
      <body>
        <div id="root">${html}</div>
        <script src="/client.js"></script>
      </body>
    </html>
  `);
}

For vanilla-extract, configure your bundler (Webpack or Vite) with the plugin and write styles in .css.ts files. The build output is static CSS, and you import class names into components. This avoids runtime style injection entirely. You can find the official integration guides at https://vanilla-extract.style/documentation/getting-started/.

Distinguishing features: What makes CSS-in-JS stand out

  • Co-location reduces cognitive load. When you open a component, you see the styles it uses, not a separate stylesheet you must cross-reference.
  • Props-driven styles and variants simplify API surfaces. A single Button with variant, size, and state props replaces multiple similarly named classes.
  • Theming is first-class. You can switch themes globally without rewriting component internals.
  • Refactoring is safer. When you move or delete a component, the styles move with it or are clearly flagged.
  • Ecosystem integration. CSS-in-JS pairs well with component libraries, design tokens, and TypeScript. Strong typing for tokens prevents typos and drift.

Practical outcomes:

  • Fewer global style bugs and naming collisions.
  • Faster iteration on component variants.
  • Improved developer experience with type-safe tokens.
  • Easier enforcement of design system constraints.

If your team values these outcomes and can absorb the overhead, CSS-in-JS pays dividends. If your app is small, static, or highly performance-sensitive, other solutions may fit better.

Free learning resources

Summary: Who should use CSS-in-JS, and who might skip it

Use CSS-in-JS if:

  • You build component libraries or design systems with many variants and shared tokens.
  • You need dynamic theming or runtime style changes based on props or user input.
  • Your team prefers co-locating styles and benefits from typed tokens.
  • You have the infrastructure for SSR and build tooling to support either runtime or zero-runtime libraries.

Skip CSS-in-JS if:

  • Your app is small or mostly static, and the overhead isn’t justified.
  • Strict performance budgets demand minimal runtime code and static caching, and you don’t want build complexity.
  • You prefer keeping styles in traditional CSS modules or utility classes for simplicity.
  • Your team is already effective with utility-first CSS and sees limited value in style co-location.

The key takeaway: CSS-in-JS is a powerful tool, but it’s not a silver bullet. It’s most valuable in contexts where dynamic styles, shared tokens, and component boundaries matter most. If those align with your project, it can reduce friction and improve maintainability. If not, lighter CSS approaches may serve you better. Whatever you choose, keep your styles explicit, your tokens consistent, and your architecture aligned with the way your team builds and ships UI.