Back to Blog
Frontend15 min readJun 2026

Styling Strategies: Utility CSS, CSS-in-JS, CSS Modules, and Zero-Runtime

Five ways to organize styles at scale, plain CSS, CSS Modules, Tailwind, runtime CSS-in-JS, and zero-runtime, and an honest 2026 guide to picking the right one for your project.

CSSTailwindCSS-in-JSStyling
SB

Sri Balaji

Founder

On this page

The styling fork in the road

Who this is for

You can write CSS, but every new project asks the same question: **plain CSS? Tailwind? styled-components? Something newer?** This article maps the five mainstream strategies in 2026, shows the *same button* in four of them, and gives you a decision list so you stop re-litigating the choice on every repo.

Styling a single component is easy. Styling 500 components across a team of 12 for three years is the real problem. Names collide. Specificity wars break out. Nobody dares delete a class because they cannot prove it is unused. The strategy you pick is really a bet about how you will *scope*, *theme*, *share*, and *delete* styles at scale.

There is no universal winner. Each approach trades developer experience against bundle size, runtime cost, and, newly important since React Server Components went mainstream, whether it even *works* on the server. Let us make those trade-offs concrete.

Off-the-rack vs bespoke: a mental model

Buying off-the-rack in standard sizes (S/M/L)Utility-first CSS (Tailwind), compose pre-made classes like `flex`, `p-4`, `text-sm`
A tailor who measures you and sews a one-off suitRuntime CSS-in-JS, styles computed per-component, in JS, at render time
A made-to-measure shop that cuts from your pattern but sews it in the factoryZero-runtime CSS-in-JS, you author in JS/TS, a build step extracts static CSS
Labelling each garment so it never gets mixed up in the washCSS Modules, locally scoped class names, collision-proof by construction
The spectrum runs from 'reuse standard parts fast' to 'fully bespoke per component'. Most teams land somewhere in between.
Pick the strategy whose failure mode you can live with: utility-first gets verbose markup, runtime CSS-in-JS gets a runtime tax, and plain CSS gets naming chaos.

The same button, four ways

Nothing clarifies the trade-offs like one component built five times. Here is a primary button, same look, same hover, same disabled state, authored in four representative strategies. Read them side by side and watch *where the styling lives*.

1. CSS Modules (plain CSS, locally scoped)

Button.module.css
css
.button {
  padding: 0.5rem 1rem;
  border-radius: 0.5rem;
  font-weight: 600;
  color: white;
  background: var(--color-primary);
  transition: background 0.15s ease;
}
.button:hover {
  background: var(--color-primary-hover);
}
.button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
Button.tsx
tsx
import styles from './Button.module.css';

export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  return <button className={styles.button} {...props} />;
}

2. Tailwind (utility-first)

Button.tsx
tsx
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  return (
    <button
      className="px-4 py-2 rounded-lg font-semibold text-white bg-primary transition-colors hover:bg-primary-hover disabled:opacity-50 disabled:cursor-not-allowed"
      {...props}
    />
  );
}

3. styled-components (runtime CSS-in-JS)

Button.tsx
tsx
'use client';
import styled from 'styled-components';

export const Button = styled.button`
  padding: 0.5rem 1rem;
  border-radius: 0.5rem;
  font-weight: 600;
  color: white;
  background: ${(p) => p.theme.colors.primary};
  transition: background 0.15s ease;
  &:hover { background: ${(p) => p.theme.colors.primaryHover}; }
  &:disabled { opacity: 0.5; cursor: not-allowed; }
`;

4. vanilla-extract (zero-runtime CSS-in-JS)

Button.css.ts
typescript
import { style } from '@vanilla-extract/css';
import { vars } from './theme.css';

export const button = style({
  padding: '0.5rem 1rem',
  borderRadius: '0.5rem',
  fontWeight: 600,
  color: 'white',
  background: vars.colors.primary,
  transition: 'background 0.15s ease',
  selectors: {
    '&:hover': { background: vars.colors.primaryHover },
    '&:disabled': { opacity: 0.5, cursor: 'not-allowed' },
  },
});

Spot the difference

Three of these compile to **static CSS at build time** (Modules, Tailwind, vanilla-extract). Only styled-components ships a runtime that *generates and injects* CSS in the browser as components render, that is the line that matters most in 2026.

The big comparison

Five strategies against the five questions that actually decide a project. 'RSC-friendly' means it works inside a React Server Component without a 'use client' boundary.

ApproachScopingRuntime costRSC-friendly?ThemingDX
Plain CSS / BEMGlobal (manual via naming)ZeroYesCSS variablesFamiliar, but naming discipline is on you
CSS ModulesLocal (auto-hashed)ZeroYesCSS variablesGreat; co-located, no new syntax
Tailwind (utility)Local (atomic, no names)Near-zero (static, deduped)YesConfig tokens + CSS varsFast once learned; markup gets noisy
Runtime CSS-in-JSLocal (auto-hashed)Real (JS exec + injection)No (needs client boundary)JS theme object via contextExcellent ergonomics, dynamic props
Zero-runtime CSS-in-JSLocal (auto-hashed)Zero (extracted at build)YesTyped token contractsGood; build setup + some constraints
Tailwind, CSS Modules, and zero-runtime libraries all ship static CSS and play nicely with server components. Runtime CSS-in-JS is the outlier.

The RSC problem with runtime CSS-in-JS

Runtime CSS-in-JS fights React Server Components

Libraries like styled-components and emotion generate styles **during render in the browser** and rely on React Context for theming. Server Components do not run in the browser and cannot consume client Context, so any styled component must sit behind a `'use client'` boundary, pulling it (and its tree) out of the server-rendering path. You lose the main RSC benefit: shipping less JavaScript.

It runs deeper than a directive. The styled-components team itself recommended against using it in new React Server Component apps and the project entered maintenance mode, explicitly because the runtime model is fundamentally at odds with server rendering. Emotion works in the App Router only with extra SSR glue and still forces client boundaries. If you adopt one of these in a Next.js App Router project today, you are swimming upstream.

  • Runtime cost: every styled component parses template literals, computes a hash, and injects a <style> tag as it mounts, measurable on large, interactive trees.
  • Hydration overhead: the style rules must be serialized server-side and re-matched client-side, adding payload and work.
  • Client boundary creep: one styled component near the top of a tree can force a large subtree to become client-rendered.

The industry response was zero-runtime CSS-in-JS: keep the authoring ergonomics (write styles in TS, get type-safe tokens) but extract everything to a static `.css` file at build time. vanilla-extract, Linaria, and Panda CSS all do this. You get scoping and typed theming with *zero* browser runtime and full RSC compatibility.

Scoping and specificity, solved differently

Every strategy is really an answer to one question: how do I stop styles from leaking? Plain CSS leaves it to you (BEM conventions, discipline). Everything else solves it mechanically.

  • CSS Modules & CSS-in-JS hash class names (.button becomes .Button_button__a1b2c), so collisions are impossible by construction.
  • Tailwind sidesteps naming entirely, utilities are atomic and shared, so there is nothing to collide. Specificity stays flat (single class selectors), which kills specificity wars.
  • The `!important` and deep-selector arms race that plagues large plain-CSS codebases simply does not happen when every selector is one flat, scoped class.

Modern CSS shrinks the gap

Native `@layer` (cascade layers), `:where()` for zero-specificity selectors, and scoped `@scope` rules now let *plain CSS* tame specificity without a tool. They do not solve naming/dead-code, but they make 'just write CSS' more viable than it was a few years ago. See [modern CSS layout: Flexbox, Grid, and container queries](/blog/modern-css-grid-flexbox-container-queries).

Theming: CSS variables are the common denominator

Whatever strategy you pick, runtime theming in 2026 should ride on CSS custom properties, not a JavaScript theme object. CSS variables cascade, respond to media queries and data-theme attributes, and switch themes with zero re-render, no React Context, no JS execution.

tokens.css
css
:root {
  --color-primary: #ec4899;
  --color-primary-hover: #db2777;
  --color-bg: #ffffff;
}
[data-theme='dark'] {
  --color-bg: #0a0a0a;
  --color-primary: #f472b6;
}

Now *every* approach consumes the same source of truth: Tailwind maps tokens to utilities, CSS Modules and plain CSS use var(--color-primary), and zero-runtime libraries generate typed contracts that compile down to the same variables. Switching to dark mode is one attribute flip on <html>, instant, no flash, no JS.

The JS theme-object trap

Runtime CSS-in-JS theming via `<ThemeProvider>` puts your tokens in React Context. Changing the theme re-renders the subtree and cannot be read from a Server Component. Prefer CSS variables and reserve the JS object for build-time token definitions only. For the full token system, see [design tokens and theming](/blog/design-tokens-and-theming).

How to choose for a given project

There is no single right answer, but the decision is fast once you anchor on your rendering model and team. Walk this list top to bottom and stop at the first match.

  1. Next.js App Router / heavy RSC, shipping fast? Use Tailwind. Zero runtime, RSC-native, huge ecosystem, and the verbose-markup complaint is real but manageable with component extraction.
  2. **Want CSS-in-JS ergonomics *and* RSC compatibility? Use a zero-runtime library, vanilla-extract (typed, framework-agnostic), Linaria (familiar styled syntax), or Panda CSS** (Chakra's successor, recipes + tokens).
  3. Small / co-located, no new deps, plain CSS is fine? Use CSS Modules, zero runtime, RSC-safe, no learning curve, ships with every framework.
  4. Pure SPA (Vite/CRA), no server components, team loves the styled API? Runtime CSS-in-JS (emotion) is still acceptable here, the RSC penalty does not apply.
  5. A design system shared across many apps? Lean zero-runtime + typed tokens for type safety and portability, or Tailwind with a shared preset for speed.

The choice in one line

  • Default to **Tailwind** or **CSS Modules** for new RSC apps, both are zero-runtime and server-safe.
  • Reach for **zero-runtime CSS-in-JS** (vanilla-extract / Panda / Linaria) when you want typed styles-in-TS without the runtime tax.
  • Only reach for **runtime CSS-in-JS** in a pure SPA with no server components.

Common mistakes that cost hours

  1. Adopting runtime CSS-in-JS in a Next.js App Router project, then fighting 'use client' boundaries and SSR flicker for weeks. Check your rendering model *first*.
  2. Theming through a JS context object when CSS variables would switch themes with zero re-render and work in Server Components.
  3. Treating Tailwind's long `className` as a code smell and 'fixing' it with @apply everywhere, you recreate the naming/dead-code problem you escaped. Extract a *component*, not a class.
  4. Mixing three strategies in one codebase without a rule, so every file is a coin flip. Pick one primary strategy; allow at most one escape hatch.
  5. Forgetting Tailwind's content/purge config, so either unused classes bloat the bundle or used classes get stripped in production.
  6. Inline `style={{}}` for anything beyond truly dynamic values, it skips the cascade, cannot do pseudo-states or media queries, and tanks reuse.

Takeaways and where to go next

The whole article in seven lines

  • Your styling strategy is a bet on how you scope, theme, share, and delete styles at scale.
  • Five options: plain CSS/BEM, CSS Modules, Tailwind (utility), runtime CSS-in-JS, zero-runtime CSS-in-JS.
  • Runtime CSS-in-JS (styled-components/emotion) carries a real runtime cost and fights React Server Components.
  • styled-components is in maintenance mode and is not recommended for new RSC apps.
  • Zero-runtime (vanilla-extract, Linaria, Panda) keeps the ergonomics and ships static CSS, RSC-safe.
  • Theme with CSS variables, not JS context: zero re-render, server-safe, instant dark mode.
  • Default to Tailwind or CSS Modules for new RSC projects; choose deliberately, then stay consistent.

Styling is one layer of the frontend stack. Once you have picked a strategy, the next questions are how to lay components out and how to structure them into a reusable system.

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.