Skip to content

Web Development · Frontend Architecture

CSS Architecture in 2026: Choosing Between Tailwind, CSS Modules, and CSS-in-JS

Three different approaches to styling a web application — Tailwind, CSS Modules, and CSS-in-JS libraries — and the team contexts where each one actually makes sense.

Anurag Verma

Anurag Verma

7 min read

CSS Architecture in 2026: Choosing Between Tailwind, CSS Modules, and CSS-in-JS

Sponsored

Share

The CSS wars have been won and lost many times. styled-components peaked, Tailwind took over GitHub stars, CSS Modules quietly became the default in new Next.js projects, and now the native cascade is good enough that some teams are dropping utility classes entirely. The cycle continues.

What hasn’t changed: picking the wrong approach for your team’s context creates friction that compounds over months. The right approach isn’t about what’s technically superior. It’s about what fits how your team works.

Here’s how to think through the choice in 2026.

What You’re Actually Deciding

When you pick a styling system, you’re making a few tradeoffs that interact with each other in non-obvious ways:

  • Co-location vs separation: Should styles live next to the component that uses them, or in separate files?
  • Build-time vs runtime: Are styles computed at build time (CSS files) or at runtime (CSS-in-JS injecting style tags)?
  • Abstraction level: Do you write raw CSS properties, utility classes, or a component API?
  • Type safety: Can TypeScript know which tokens your design system defines?

These tradeoffs produce different outcomes depending on whether you’re a team of two or twenty, building a marketing site or a SaaS dashboard, and whether you have a designer working in the same codebase.

Tailwind: The Utility-First Case

Tailwind CSS works by giving you utility classes that map directly to CSS properties. You style by composing classes directly in your HTML or JSX.

// Tailwind: styles are the class names
export function AlertBanner({ type, message }) {
  return (
    <div className={`
      flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium
      ${type === 'error' 
        ? 'bg-red-50 text-red-800 border border-red-200' 
        : 'bg-blue-50 text-blue-800 border border-blue-200'
      }
    `}>
      {message}
    </div>
  );
}

The case for Tailwind is real. You never write a CSS file. You never name things. You never hunt for which file contains the style you need to change. The build output is small because PurgeCSS removes unused classes. For teams without designers involved in code review, Tailwind creates a shared visual vocabulary.

The case against is also real. The class strings get long. Logic mixed into classnames (type === 'error' ? ...) produces the worst of both worlds. Responsiveness across many breakpoints becomes hard to scan. Without consistent use of @apply or component extraction, similar UI elements end up with similar but slightly different class strings across the codebase.

Tailwind 4 (released early 2025) moved the configuration to CSS rather than JavaScript, which simplified setup significantly and improved performance. The @theme directive replaces the config object:

/* tailwind.css — Tailwind 4 config */
@import "tailwindcss";

@theme {
  --color-brand: oklch(55% 0.18 250);
  --font-mono: "JetBrains Mono", monospace;
  --spacing-content: 64rem;
}

When Tailwind makes sense: teams where most developers touch styles occasionally rather than constantly, projects with a stable design system that maps cleanly to tokens, and apps where shipping fast matters more than long-term maintainability.

When it doesn’t: large teams where component boundaries need to be strict, any project with a designer who doesn’t want to work in utility classes, and cases where you need runtime style computation (theming based on user preferences stored in a database, for example).

CSS Modules: The Boring, Reliable Choice

CSS Modules scope class names to the component that imports them. You write normal CSS, import the styles as an object, and reference class names as properties.

// AlertBanner.module.css
.container {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  border-radius: 0.5rem;
  padding: 0.75rem 1rem;
  font-size: 0.875rem;
  font-weight: 500;
  border: 1px solid;
}
.error {
  background-color: #fef2f2;
  color: #991b1b;
  border-color: #fecaca;
}
.info {
  background-color: #eff6ff;
  color: #1e40af;
  border-color: #bfdbfe;
}
// AlertBanner.jsx
import styles from './AlertBanner.module.css';

export function AlertBanner({ type, message }) {
  return (
    <div className={`${styles.container} ${styles[type]}`}>
      {message}
    </div>
  );
}

The class name container in the compiled output becomes something like AlertBanner_container__xK2d — scoped to this module, impossible to accidentally collide with another component’s container class. No configuration required beyond what comes with Vite, Next.js, or any modern build tool.

CSS Modules have no runtime overhead. They compile to static CSS files. They support the full range of CSS features: custom properties, @keyframes, pseudo-elements, media queries, container queries. They work anywhere CSS works.

The main friction: styles and components live in separate files, which means more switching. And you’re naming things, which requires judgment. Whether these are problems depends on how your team works.

When CSS Modules make sense: teams with a designer-developer workflow where CSS is treated as a first-class artifact, larger codebases where strict encapsulation matters, projects that need to work across multiple rendering environments (server-rendered, edge-rendered, native via React Native Web).

When it doesn’t: small teams where the two-file overhead feels slow, design systems where you want a single token source with TypeScript autocomplete.

CSS-in-JS: The Nuanced Picture

CSS-in-JS has been declared dead several times. It’s not dead, but it has fragmented into meaningfully different approaches with different performance profiles.

Runtime CSS-in-JS (styled-components, Emotion) computes styles in JavaScript at render time and injects them into the document. This enables dynamic styling based on any JavaScript value — props, state, context, theme from a database. It also means you ship a JavaScript runtime, generate class names on every render, and block page paint until the script runs. For large React apps, these costs are significant.

Zero-runtime CSS-in-JS (vanilla-extract, Linaria, Pigment CSS) processes styles at build time and outputs static CSS files. The API looks like runtime CSS-in-JS, but there’s no runtime cost:

// styles.css.ts — vanilla-extract (build time only)
import { style, createThemeContract } from '@vanilla-extract/css';

export const vars = createThemeContract({
  color: {
    brand: null,
    surface: null,
  },
  font: {
    body: null,
  }
});

export const container = style({
  display: 'flex',
  alignItems: 'center',
  gap: vars.spacing.small,
  fontFamily: vars.font.body,
});

export const error = style({
  backgroundColor: '#fef2f2',
  color: '#991b1b',
  borderColor: '#fecaca',
});

TypeScript validates that you’re using real tokens. The output is a static CSS file. You get autocomplete on your design tokens. The tradeoff is that styles can’t depend on runtime values; you’d need CSS custom properties for theming that changes based on user preference.

Pigment CSS (from the MUI team) takes this further — it’s specifically designed to replace Emotion in MUI v7 with a zero-runtime approach that maintains the same API surface.

When runtime CSS-in-JS makes sense: large design systems where TypeScript-level token safety is a hard requirement, teams already heavily invested in the ecosystem (MUI, Chakra), situations where you genuinely need styles computed from dynamic runtime data.

When to move away from it: any server-rendered application where hydration performance matters, new projects where you don’t already have a runtime CSS-in-JS investment.

The Decision Framework

Four questions that usually produce a clear answer:

1. Do you have a designer in the codebase? If yes, they probably prefer working with CSS files rather than JSX classnames. CSS Modules is the path of least resistance.

2. Is runtime performance a hard requirement? If you’re hitting Core Web Vitals targets for a public-facing site with SSR, anything that adds to the client JavaScript bundle for styling should be scrutinized. CSS Modules and Tailwind both produce static CSS.

3. How large is the design system and team? Small design system, small team: Tailwind is fast to work with. Large design system, need TypeScript token validation: vanilla-extract. Somewhere in between: CSS Modules with CSS custom properties for theming.

4. Are you starting fresh or migrating? Migrations are expensive. If you have 200 components in styled-components and the app works, don’t migrate unless you have a specific performance problem to solve. Refactor incrementally toward CSS Modules or vanilla-extract only when it pays for itself.

What’s Actually Happening in 2026

The net direction of the ecosystem has been away from runtime CSS-in-JS and toward either utility classes (Tailwind) or build-time solutions (CSS Modules, vanilla-extract). This isn’t trend-chasing — it’s a response to real performance problems that became visible as Core Web Vitals became a ranking signal and server-rendered React frameworks became standard.

Styled-components’ download trends have declined for three consecutive years. CSS Modules usage in Next.js projects has grown consistently. Tailwind remains the most-watched CSS project on GitHub.

None of this means you have to change what’s working. It means that for new projects in 2026, the default is probably not a runtime CSS-in-JS library unless you have a specific reason to reach for one.

Sponsored

Enjoyed it? Pass it on.

Share this article.

Sponsored

The dispatch

Working notes from
the studio.

A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored