Type something to search...
CSS-in-JS vs. CSS Modules: Choosing a Styling Strategy

CSS-in-JS vs. CSS Modules: Choosing a Styling Strategy

Every component-based project eventually has to answer the same question: where do the styles live, and how do you stop one component's CSS from leaking into another? For years the two most popular answers in the React world have been CSS-in-JS, where styles are written in JavaScript alongside the component, and CSS Modules, where styles stay in .css files but class names are scoped automatically at build time.

Both solve the global-namespace problem. They differ a lot in how they do it, what they cost at runtime, and how well they fit modern frameworks built around server rendering. This guide compares them side by side, with code, so you can pick the right approach for your next project instead of defaulting to whatever you used last time.

The Problem Both Approaches Solve

Plain CSS has one global namespace. If two developers each write a .title class, the one that loads later wins, and the bug shows up somewhere nobody is looking. Naming conventions like BEM reduce the risk, but they depend on discipline, and discipline doesn't scale well across large teams.

Scoped styling tools fix this mechanically. Instead of trusting everyone to pick unique names, the tool generates them. The difference between approaches is when and how that generation happens:

  • Runtime CSS-in-JS generates class names and injects style rules in the browser while your app runs.
  • Zero-runtime CSS-in-JS lets you write styles in JavaScript or TypeScript, then extracts them to static CSS files at build time.
  • CSS Modules keep you in plain CSS and rewrite class names at build time.

CSS Modules in Practice

A CSS Module is just a CSS file with a .module.css extension. Most bundlers and frameworks, including Vite, Next.js, and webpack-based setups, support them without extra configuration.

/* Button.module.css */
.button {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.625rem 1.25rem;
  border: 0;
  border-radius: 0.5rem;
  font-weight: 600;
  background: #0ea5e9;
  color: #fff;
  cursor: pointer;
}

.button:hover {
  background: #0284c7;
}

.secondary {
  background: transparent;
  color: #0ea5e9;
  box-shadow: inset 0 0 0 2px currentColor;
}
// Button.jsx
import styles from "./Button.module.css";

export function Button({ variant = "primary", children, ...props }) {
  const className =
    variant === "secondary"
      ? `${styles.button} ${styles.secondary}`
      : styles.button;

  return (
    <button className={className} {...props}>
      {children}
    </button>
  );
}

At build time, .button becomes something like .Button_button__x7Kq2. The styles object maps your readable names to the generated ones. Two components can both have a .button class without any conflict.

Composition and Global Escapes

CSS Modules support composes, which lets one class include another, even from a different file:

/* typography.module.css */
.label {
  font-size: 0.875rem;
  letter-spacing: 0.02em;
  text-transform: uppercase;
}

/* Badge.module.css */
.badge {
  composes: label from "./typography.module.css";
  padding: 0.125rem 0.5rem;
  border-radius: 999px;
  background: #e0f2fe;
}

When you genuinely need a global selector, for example to style markup injected by a third-party library, use :global:

.editor :global(.ProseMirror) {
  min-height: 12rem;
  outline: none;
}

What You Get

  • Zero runtime cost. The browser receives ordinary CSS files. Nothing is computed in JavaScript.
  • All of CSS. Media queries, container queries, @supports, cascade layers, :has(), nesting, and every new feature work exactly as they do in plain CSS, because it is plain CSS.
  • Great caching. Stylesheets are static assets that the browser can cache and download in parallel with your JavaScript.
  • Framework-neutral. The same approach works in React, Vue, Svelte, or server-rendered templates.

What You Give Up

  • Dynamic styles need a bridge. You can't reference a prop directly in the stylesheet. The usual answer is CSS custom properties, covered below.
  • Two files per component. Some developers find switching between the component and its stylesheet slower than colocated styles.
  • Weak type safety by default. A typo like styles.buton gives you undefined at runtime. Plugins such as typescript-plugin-css-modules can generate types and catch this in your editor.

Runtime CSS-in-JS in Practice

Libraries like styled-components and Emotion popularized writing styles as tagged template literals or objects inside your components:

import styled from "styled-components";

const Button = styled.button`
  display: inline-flex;
  align-items: center;
  padding: 0.625rem 1.25rem;
  border-radius: 0.5rem;
  font-weight: 600;
  border: 0;
  cursor: pointer;
  background: ${(props) => (props.$variant === "secondary" ? "transparent" : "#0ea5e9")};
  color: ${(props) => (props.$variant === "secondary" ? "#0ea5e9" : "#fff")};

  &:hover {
    filter: brightness(0.95);
  }
`;

export default function Toolbar() {
  return (
    <>
      <Button>Save</Button>
      <Button $variant="secondary">Cancel</Button>
    </>
  );
}

Here styles and logic sit together, props flow straight into CSS, and there's nothing to wire up. It's a genuinely pleasant developer experience, which is why it became so popular.

The Runtime Cost

That convenience has a price. When the component renders, the library has to serialize the styles, hash them to produce a class name, and insert a rule into a <style> tag if it hasn't seen that combination before. That work happens on the main thread, during render, on every device that loads your app.

On a fast laptop you'll rarely notice. On a mid-range phone rendering a long list with prop-driven styles, the overhead shows up in profiles as extra scripting time. Each unique prop combination can also generate a new rule, which grows the style sheet over the life of the page and triggers style recalculation.

The Server Rendering Problem

The bigger shift is architectural. Frameworks now render much of the UI on the server, and React Server Components in particular don't support the context and runtime hooks that libraries like styled-components and Emotion depend on. You can still use them in components marked "use client", with extra setup to collect styles during streaming, but you lose the benefit of rendering those components on the server.

That's not a theoretical concern. In early 2025, the styled-components maintainers announced that the library was entering maintenance mode, citing this shift as a key reason and recommending that new projects look elsewhere. Existing apps keep working, but it's a strong signal about where the ecosystem is going.

Zero-Runtime CSS-in-JS: The Middle Ground

A newer group of libraries keeps the "styles in TypeScript" authoring model but moves the work to build time. Popular options include vanilla-extract, Linaria, Panda CSS, and Meta's StyleX. Each has its own API, but the idea is the same: styles are evaluated during the build and emitted as static CSS files.

Here's a vanilla-extract example:

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

const base = style({
  display: "inline-flex",
  alignItems: "center",
  padding: "0.625rem 1.25rem",
  borderRadius: "0.5rem",
  fontWeight: 600,
  border: 0,
  cursor: "pointer",
});

export const button = styleVariants({
  primary: [base, { background: "#0ea5e9", color: "#fff" }],
  secondary: [
    base,
    {
      background: "transparent",
      color: "#0ea5e9",
      boxShadow: "inset 0 0 0 2px currentColor",
    },
  ],
});
// Button.tsx
import { button } from "./button.css";

type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: keyof typeof button;
};

export function Button({ variant = "primary", ...props }: Props) {
  return <button className={button[variant]} {...props} />;
}

You get type-checked variants, autocompletion, and styles that ship as a normal CSS file. The trade-off is that values must be knowable at build time. You can't pass an arbitrary runtime value into a style definition, so truly dynamic values go through CSS custom properties, just like with CSS Modules.

These tools also need a bundler plugin, which ties you more tightly to a specific build setup than CSS Modules do.

Handling Dynamic Styles Without a Runtime

The most common reason people reach for runtime CSS-in-JS is dynamic values: a progress bar width, a user-chosen accent color, a position calculated in JavaScript. Custom properties handle this cleanly in any static approach.

/* ProgressBar.module.css */
.track {
  height: 0.5rem;
  border-radius: 999px;
  background: #e2e8f0;
  overflow: hidden;
}

.fill {
  height: 100%;
  width: calc(var(--progress, 0) * 1%);
  background: var(--accent, #0ea5e9);
  transition: width 200ms ease;
}
import styles from "./ProgressBar.module.css";

export function ProgressBar({ value, accent }) {
  return (
    <div
      className={styles.track}
      role="progressbar"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={100}
    >
      <div
        className={styles.fill}
        style={{ "--progress": value, "--accent": accent }}
      />
    </div>
  );
}

The stylesheet stays static and cacheable. Only the variable values change, and updating a custom property through an inline style is cheap. No new rules are generated, no class names are hashed.

For discrete states, prefer data attributes or ARIA attributes over conditional class strings. They double as documentation and often improve accessibility:

.tab[aria-selected="true"] {
  border-bottom-color: currentColor;
  font-weight: 600;
}

.card[data-size="compact"] {
  padding: 0.75rem;
}

Side-by-Side Comparison

ConcernCSS ModulesRuntime CSS-in-JSZero-runtime CSS-in-JS
Runtime costNoneStyle serialization and injection during renderNone
OutputStatic CSS files<style> tags injected by JSStatic CSS files
Server ComponentsWorksClient components onlyWorks (depends on library)
Dynamic valuesCustom propertiesProps directlyCustom properties
Type safetyOptional pluginGoodExcellent
Access to new CSS featuresImmediateMostly, via stringsMostly, via library support
Build setupBuilt into most toolsLibrary plus SSR setupBundler plugin required
Learning curveJust CSSLibrary APILibrary API

Where Utility-First CSS Fits

It's worth mentioning the third path many teams now take: utility-first CSS such as Tailwind. It also produces a static stylesheet with no runtime, sidesteps naming entirely, and works fine with server rendering. Many projects combine utilities for layout and spacing with CSS Modules for complex, component-specific styling like animations or intricate selectors. These approaches aren't mutually exclusive.

How to Choose

Rather than asking which approach is best, ask what your project needs.

Choose CSS Modules if

  • You're using a server-first framework and want styles that just work in server components.
  • Your team is comfortable with CSS and wants to use new features as soon as browsers ship them.
  • You care about runtime performance on low-end devices.
  • You want the smallest possible dependency on any particular library.

Choose zero-runtime CSS-in-JS if

  • Type safety for design tokens and variants matters a lot to your team.
  • You're building a design system and want the API to enforce consistency.
  • You're fine with a build plugin and some constraints on what can be dynamic.

Keep runtime CSS-in-JS if

  • You have a large, working client-rendered app already built on it, and migrating wouldn't pay for itself.
  • Your app is mostly client-rendered and the runtime overhead doesn't show up in your profiles.

For a brand-new project in 2026, runtime CSS-in-JS is hard to recommend. The ecosystem has moved toward static extraction, and the main libraries' own maintainers point new projects elsewhere.

Migrating Incrementally

If you're moving off a runtime library, you don't need a big-bang rewrite. Both approaches can coexist in the same app.

  1. Start with leaf components. Buttons, badges, and inputs have small, self-contained styles and are easy to convert.
  2. Move design tokens to custom properties first. Define colors, spacing, and radii on :root. Both old and new components can read them, which keeps the visual language consistent during the transition.
  3. Replace prop-driven styles with data attributes or custom properties. This is usually the bulk of the work.
  4. Measure as you go. Compare scripting time and total blocking time in the Performance panel before and after converting heavy screens.
  5. Remove the library last, once nothing imports it, along with its SSR setup code.
/* tokens.css - loaded once, globally */
:root {
  --color-accent: #0ea5e9;
  --color-accent-strong: #0284c7;
  --radius-md: 0.5rem;
  --space-3: 0.75rem;
  --space-5: 1.25rem;
}

Common Pitfalls

  • Overusing :global in CSS Modules. A few escapes are fine. If half your file is global, you've recreated the problem you were trying to solve.
  • Building class names from strings. Writing styles[`${size}Button`] works, but it hides which classes are used and breaks editor tooling. A small map object is clearer.
  • Generating a rule per value in runtime CSS-in-JS. Passing something like a mouse position or scroll offset as a prop creates a new style rule on every change. Use a custom property via the style attribute instead.
  • Forgetting specificity when mixing approaches. Injected runtime styles and static CSS files can load in a different order than you expect. Cascade layers help here: put each source in its own @layer and control the order explicitly.

Conclusion

CSS-in-JS and CSS Modules both exist to give components private, predictable styles. Runtime CSS-in-JS delivers the smoothest authoring experience but pays for it in the browser and fits poorly with server-first frameworks. Zero-runtime libraries keep the TypeScript ergonomics and ship static CSS, at the cost of a build plugin and some constraints. CSS Modules are the simplest of all: plain CSS, scoped automatically, with nothing running in the browser.

For most new projects, a static approach, CSS Modules or a zero-runtime library, possibly alongside utility classes, is the safest default. Use custom properties for dynamic values, keep tokens in one place, and your styles will stay fast, portable, and easy to reason about as the project grows.

Tags :
Share :

Related Posts

A Complete Guide to CSS Container Queries

A Complete Guide to CSS Container Queries

For more than a decade, responsive design meant one thing: media queries. You asked the browser how wide the viewport was and adjusted your layout ac

Continue Reading
A Comprehensive Guide to Installing Next.js

A Comprehensive Guide to Installing Next.js

Next.js has emerged as a powerful framework for building React applications, offering features like server-side rendering, static site generation, an

Continue Reading
Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

Advanced CSS with clamp(), min(), and max(): Simplifying Dynamic Styling

CSS has evolved significantly, and modern tools like clamp(), min(), and max() are powerful game-changers in dynamic styling. If you’ve struggl

Continue Reading