Skip to content
Unverified — AI-generated content. Help verify this page

Tailwind CSS vs CSS Modules vs Styled Components vs Vanilla CSS

How you write CSS affects bundle size, runtime performance, developer velocity, and design consistency. The styling landscape has fragmented into fundamentally different philosophies. This page compares the four most widely used approaches across every dimension that matters.

Overview

Tailwind CSS

Tailwind CSS is a utility-first CSS framework created by Adam Wathan in 2017. Instead of writing custom CSS classes, you compose styles from pre-defined utility classes directly in your HTML/JSX (flex items-center gap-4 bg-blue-500 text-white). Tailwind uses a JIT (just-in-time) compiler that scans your source files and generates only the CSS classes you actually use. Tailwind v4 (2025) rewrote the engine in Rust (using Oxide), added CSS-first configuration, and runs as a native CSS tool rather than a PostCSS plugin.

CSS Modules

CSS Modules is a specification (not a framework) that scopes CSS class names to the component that imports them. When you import styles.module.css, the build tool (Vite, Webpack) transforms each class name into a unique hash (header becomes _header_1a2b3), preventing naming collisions. CSS Modules let you write standard CSS with automatic scoping — no runtime, no special syntax, no vendor lock-in.

Styled Components

Styled Components is a CSS-in-JS library created by Max Stoiber and Glen Maddern in 2016. It uses tagged template literals to write actual CSS inside JavaScript/TypeScript components. Styled Components generates unique class names at runtime, injects <style> tags into the document head, and supports dynamic styles based on props. The v6 release improved SSR performance, but CSS-in-JS as a category has faced pushback due to runtime performance costs.

Vanilla CSS

Vanilla CSS refers to writing standard CSS (or preprocessed CSS via Sass/PostCSS) without any framework or build-time transformation beyond standard processing. Modern CSS has eliminated many reasons for CSS-in-JS and utility frameworks — CSS nesting, :has(), container queries, cascade layers (@layer), and @scope provide solutions for problems that previously required tooling. Vanilla CSS with modern features is increasingly viable for complex applications.

Architecture Comparison

Key Architectural Differences

Tailwind is a build-time tool. It scans your source files for class names, generates only the CSS for classes you actually use, and outputs a single, optimized stylesheet. There is zero runtime JavaScript. The v4 engine written in Rust is extremely fast.

CSS Modules is a build-time convention. Your build tool transforms class names to ensure uniqueness, then outputs standard CSS. The runtime cost is zero — the JavaScript bundle includes only a mapping object ({ header: "_header_1a2b3" }).

Styled Components runs in the browser. It parses CSS template literals at runtime, generates unique class names, and injects <style> tags into the DOM. This has a measurable performance cost — every styled component adds JavaScript to your bundle and CSS processing to your render path.

Vanilla CSS is processed by the browser's native CSS engine with no JavaScript overhead. Modern CSS features (nesting, :has(), @layer, @scope) provide many of the organizational benefits that previously required tooling.

Feature Matrix

FeatureTailwind v4CSS ModulesStyled Components v6Vanilla CSS
ApproachUtility classes in markupScoped CSS filesCSS-in-JS (runtime)Standard CSS
ScopingUnique utility namesHash-based class namesGenerated class namesManual (BEM, @scope)
Dynamic stylesArbitrary values [color:red]CSS variablesProps-based (native)CSS variables
ThemingCSS variables + configCSS variablesThemeProviderCSS variables
Design tokensConfig + CSS variablesManualThemeProviderCSS variables
Responsivemd:, lg: prefixesMedia queriesMedia queriesMedia queries + container queries
Dark modedark: prefixprefers-color-schemeTheme switchingprefers-color-scheme
Pseudo-classeshover:, focus: prefixesStandard CSS&:hover in templateStandard CSS
Animationanimate-* utilities@keyframeskeyframes helper@keyframes
TypeScriptClass name strings (no types)Typed with *.module.css.d.tsFull prop typesN/A
SSRNo issues (static CSS)No issues (static CSS)Requires setup (extraction)No issues
Runtime costZeroZeroMeasurable (~8-15 KB + parsing)Zero
Build step requiredYes (JIT compiler)Yes (build tool)No (but SSR extraction needs it)Optional (PostCSS/Sass)
Framework agnosticYesYesReact (primarily)Yes
Dev toolsTailwind IntelliSense (VS Code)Standard CSS DevToolsStyled Components DevToolsStandard CSS DevTools
Bundle size~10 KB (generated CSS)Component-specific~15 KB (runtime)Varies

Code Comparison

Card Component

tsx
export function Card({ title, description, image, featured }) {
  return (
    <article
      className={`
        rounded-xl border bg-white shadow-sm
        transition-shadow hover:shadow-md
        ${featured ? 'border-blue-500 ring-2 ring-blue-200' : 'border-gray-200'}
      `}
    >
      <img
        src={image}
        alt=""
        className="h-48 w-full rounded-t-xl object-cover"
      />
      <div className="p-6">
        <h3 className="text-lg font-semibold text-gray-900">
          {title}
        </h3>
        <p className="mt-2 text-sm text-gray-600 line-clamp-3">
          {description}
        </p>
        <button className="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
          Read more
        </button>
      </div>
    </article>
  );
}
tsx
// Card.tsx
import styles from './Card.module.css';

export function Card({ title, description, image, featured }) {
  return (
    <article className={`${styles.card} ${featured ? styles.featured : ''}`}>
      <img src={image} alt="" className={styles.image} />
      <div className={styles.content}>
        <h3 className={styles.title}>{title}</h3>
        <p className={styles.description}>{description}</p>
        <button className={styles.button}>Read more</button>
      </div>
    </article>
  );
}
css
/* Card.module.css */
.card {
  border-radius: 0.75rem;
  border: 1px solid var(--color-gray-200);
  background: white;
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
  transition: box-shadow 0.2s;

  &:hover {
    box-shadow: 0 4px 6px rgb(0 0 0 / 0.1);
  }
}

.featured {
  border-color: var(--color-blue-500);
  box-shadow: 0 0 0 2px var(--color-blue-200);
}

.image {
  width: 100%;
  height: 12rem;
  object-fit: cover;
  border-radius: 0.75rem 0.75rem 0 0;
}

.content {
  padding: 1.5rem;
}

.title {
  font-size: 1.125rem;
  font-weight: 600;
  color: var(--color-gray-900);
}

.description {
  margin-top: 0.5rem;
  font-size: 0.875rem;
  color: var(--color-gray-600);
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.button {
  margin-top: 1rem;
  padding: 0.5rem 1rem;
  font-size: 0.875rem;
  font-weight: 500;
  color: white;
  background: var(--color-blue-600);
  border: none;
  border-radius: 0.5rem;
  cursor: pointer;

  &:hover {
    background: var(--color-blue-700);
  }

  &:focus-visible {
    outline: 2px solid var(--color-blue-500);
    outline-offset: 2px;
  }
}
tsx
import styled from 'styled-components';

const StyledCard = styled.article<{ $featured?: boolean }>`
  border-radius: 0.75rem;
  border: 1px solid ${p => p.$featured ? '#3b82f6' : '#e5e7eb'};
  background: white;
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
  transition: box-shadow 0.2s;
  ${p => p.$featured && 'box-shadow: 0 0 0 2px #bfdbfe;'}

  &:hover {
    box-shadow: 0 4px 6px rgb(0 0 0 / 0.1);
  }
`;

const Image = styled.img`
  width: 100%;
  height: 12rem;
  object-fit: cover;
  border-radius: 0.75rem 0.75rem 0 0;
`;

const Content = styled.div`
  padding: 1.5rem;
`;

const Title = styled.h3`
  font-size: 1.125rem;
  font-weight: 600;
  color: #111827;
`;

const Description = styled.p`
  margin-top: 0.5rem;
  font-size: 0.875rem;
  color: #4b5563;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
`;

const Button = styled.button`
  margin-top: 1rem;
  padding: 0.5rem 1rem;
  font-size: 0.875rem;
  font-weight: 500;
  color: white;
  background: #2563eb;
  border: none;
  border-radius: 0.5rem;
  cursor: pointer;

  &:hover { background: #1d4ed8; }
  &:focus-visible {
    outline: 2px solid #3b82f6;
    outline-offset: 2px;
  }
`;

export function Card({ title, description, image, featured }) {
  return (
    <StyledCard $featured={featured}>
      <Image src={image} alt="" />
      <Content>
        <Title>{title}</Title>
        <Description>{description}</Description>
        <Button>Read more</Button>
      </Content>
    </StyledCard>
  );
}
tsx
// Card.tsx
import './Card.css';

export function Card({ title, description, image, featured }) {
  return (
    <article className={`card ${featured ? 'card--featured' : ''}`}>
      <img src={image} alt="" className="card__image" />
      <div className="card__content">
        <h3 className="card__title">{title}</h3>
        <p className="card__description">{description}</p>
        <button className="card__button">Read more</button>
      </div>
    </article>
  );
}
css
/* Card.css — BEM naming convention */
.card {
  border-radius: 0.75rem;
  border: 1px solid var(--color-gray-200);
  background: white;
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
  transition: box-shadow 0.2s;

  &:hover {
    box-shadow: 0 4px 6px rgb(0 0 0 / 0.1);
  }

  &--featured {
    border-color: var(--color-blue-500);
    box-shadow: 0 0 0 2px var(--color-blue-200);
  }
}

.card__image {
  width: 100%;
  height: 12rem;
  object-fit: cover;
  border-radius: 0.75rem 0.75rem 0 0;
}

.card__content { padding: 1.5rem; }
.card__title { font-size: 1.125rem; font-weight: 600; color: var(--color-gray-900); }
.card__description {
  margin-top: 0.5rem;
  font-size: 0.875rem;
  color: var(--color-gray-600);
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.card__button {
  margin-top: 1rem; padding: 0.5rem 1rem;
  font-size: 0.875rem; font-weight: 500;
  color: white; background: var(--color-blue-600);
  border: none; border-radius: 0.5rem; cursor: pointer;

  &:hover { background: var(--color-blue-700); }
  &:focus-visible { outline: 2px solid var(--color-blue-500); outline-offset: 2px; }
}

Performance

Runtime Performance

MetricTailwindCSS ModulesStyled ComponentsVanilla CSS
Runtime JS overhead0 KB0 KB~15 KB (library)0 KB
Style insertionStatic <link>Static <link>Dynamic <style> injectionStatic <link>
Reflow/repaint costStandardStandardHigher (CSSOM manipulation)Standard
SSR FOUC riskNoneNoneMedium (needs extraction)None
React re-render costNone (static classes)None (static classes)Creates new class per render (if dynamic)None (static classes)

Build-time Performance

MetricTailwind v4CSS ModulesStyled ComponentsVanilla CSS
Build time overheadLow (Rust engine)NegligibleNegligibleNegligible (PostCSS)
CSS output size (medium app)8-15 KB15-30 KBDynamic (per component)20-40 KB
Cache effectivenessExcellent (single file)Good (per-component)N/A (runtime)Good
Dead code eliminationAutomatic (JIT)Manual (unused classes stay)Manual (unused components)Manual

Bundle Size Impact

MetricTailwindCSS ModulesStyled ComponentsVanilla CSS
CSS size (100 components)~10-15 KB (shared utilities)~30-50 KB (per component)Generated at runtime~30-50 KB
JS size addition0 KB~1 KB (class maps)~15 KB + component overhead0 KB
Total size impactSmallestSmallLargestSmall

Styled Components performance issue

Styled Components injects <style> tags at runtime, which can cause layout shifts and FOUC (Flash of Unstyled Content) during SSR hydration. The React team has explicitly recommended against CSS-in-JS libraries that inject styles at runtime. This is the primary reason CSS-in-JS adoption has declined since 2023.

Tailwind's CSS size advantage

Tailwind's CSS output stays remarkably small because utility classes are shared across components. Whether you have 10 or 1,000 components, flex, p-4, and text-sm are only emitted once. With CSS Modules or vanilla CSS, similar styles are duplicated across every component's stylesheet.

Developer Experience

Productivity

AspectTailwindCSS ModulesStyled ComponentsVanilla CSS
Speed of stylingVery fast (inline)Medium (context switch)Medium (template strings)Medium (context switch)
Design consistencyHigh (constrained values)Depends on disciplineDepends on themeDepends on discipline
RefactoringHard (classes in markup)Easy (rename CSS class)Easy (rename component)Medium (global name risk)
Code reviewNoisy (long class strings)Clean (semantic classes)Clean (component names)Clean (semantic classes)
IDE supportTailwind IntelliSense (excellent)Standard CSS toolingGood (but less tooling)Standard CSS tooling
Finding stylesIn the component (inline)Separate fileIn the component (co-located)Separate file

Learning Curve

AspectTailwindCSS ModulesStyled ComponentsVanilla CSS
CSS knowledge requiredMedium (must know concepts)High (full CSS)High (full CSS + JS)High (full CSS)
Time to productive1-2 weeks (learn utilities)1 day (if you know CSS)1 week (JS + CSS patterns)1 day (if you know CSS)
Onboarding costLearning class namesAlmost zeroLearning the APIAlmost zero
Transferable skillsTailwind-specificUniversal CSSReact-specificUniversal CSS

Design System Integration

AspectTailwindCSS ModulesStyled ComponentsVanilla CSS
Design tokenstailwind.config / CSS varsCSS variablesThemeProviderCSS variables
Component libraryshadcn/ui, Headless UIAnyMany (MUI, Chakra)Any
Consistency enforcementBuilt-in (spacing scale)ManualManual (theme)Manual
Figma-to-codeMany pluginsManualManualManual

When to Use Which

Decision Summary

ScenarioBest ChoiceWhy
Rapid development, small teamTailwindFastest iteration, consistent output
Design system with strict tokensTailwind or CSS Modules + varsConstrained values enforce consistency
Server-side renderedTailwind or CSS ModulesZero runtime, no FOUC risk
React + highly dynamic stylesStyled Components or CSS Modules + varsProps-based style composition
Framework-agnostic stylingCSS Modules or vanilla CSSNo framework dependency
Legacy project, add scopingCSS ModulesMinimal change, just rename files
Performance-criticalTailwind or vanilla CSSZero runtime overhead
Existing Styled Components projectStay (or migrate to CSS Modules)Migration cost is real
Content site (blog, docs)TailwindQuick utility styling, small output
Long-term maintainabilityCSS Modules or vanilla CSSStandard CSS, no vendor lock-in

Migration

Styled Components to CSS Modules

  1. Create CSS file: For each styled component, create a corresponding .module.css file
  2. Move styles: Copy CSS from template literals into CSS Module classes
  3. Replace dynamic styles: Convert prop-based styles to CSS variables or data attributes
  4. Update component: Import CSS Module, apply class names
  5. Remove styled-components: Uninstall once all components are migrated
tsx
// Before (Styled Components)
const Button = styled.button<{ $variant: 'primary' | 'secondary' }>`
  padding: 0.5rem 1rem;
  border-radius: 0.5rem;
  background: ${p => p.$variant === 'primary' ? '#2563eb' : '#e5e7eb'};
  color: ${p => p.$variant === 'primary' ? 'white' : '#111827'};
`;
<Button $variant="primary">Click</Button>
css
/* After (Button.module.css) */
.button {
  padding: 0.5rem 1rem;
  border-radius: 0.5rem;
}
.primary {
  background: #2563eb;
  color: white;
}
.secondary {
  background: #e5e7eb;
  color: #111827;
}
tsx
// After (Button.tsx)
import styles from './Button.module.css';
<button className={`${styles.button} ${styles[variant]}`}>Click</button>

CSS Modules to Tailwind

  1. Install Tailwind: Follow the Tailwind installation guide for your build tool
  2. Map CSS to utilities: Convert each CSS property to Tailwind utility classes
  3. Replace className references: Change styles.card to utility strings
  4. Remove CSS files: Delete .module.css files once components are converted
  5. Extract common patterns: Use @apply in components or create Tailwind components

Tailwind migration is one-way

Migrating to Tailwind is difficult to reverse because style information moves from structured CSS files into inline class strings spread across hundreds of components. Consider carefully before committing — the approach must align with your team's philosophy long-term.

Verdict

Choose Tailwind CSS if your team values development speed, design consistency, and small CSS output. Tailwind's utility-first approach eliminates context-switching between HTML and CSS files, enforces consistent spacing/color/typography through its configuration, and produces remarkably small CSS bundles. The tradeoff is verbose markup and a learning curve for the utility class vocabulary. Tailwind v4 with its Rust engine is the most popular choice for new projects in 2026.

Choose CSS Modules if your team has strong CSS skills and wants scoped, standard CSS without runtime overhead or vendor lock-in. CSS Modules provide the core benefit of scoping (no naming collisions) without changing how you write CSS. They work with every framework, every build tool, and every deployment platform. This is the most conservative, lowest-risk choice.

Choose Styled Components only if you have an existing codebase using it and the migration cost is not justified, or if you have a genuinely compelling need for highly dynamic, props-driven styles that CSS variables cannot express. For new projects in 2026, the runtime performance cost and SSR complexity make CSS-in-JS hard to recommend. The React team and the broader community have moved away from runtime CSS-in-JS.

Choose Vanilla CSS if your team is disciplined about naming conventions, your project is framework-agnostic, or you want zero tooling dependencies. Modern CSS (nesting, :has(), container queries, @layer, @scope) has closed most of the gaps that CSS tooling was created to address. The risk is naming collisions at scale — use BEM, @scope, or @layer to mitigate.

Which Would You Choose?

Scenario 1: You are building an admin dashboard with 200+ components. Consistency across the team is critical, and you want tight constraints on spacing, color, and typography.

Recommendation: Tailwind CSS

Tailwind's configuration enforces design tokens — the spacing scale, color palette, and typography are constrained by default. A developer cannot use arbitrary padding values without explicitly reaching for arbitrary values ([padding:13px]). This constraint system produces consistent UIs across large teams without a design system library.

Scenario 2: You are building a component library that will be published as an npm package. Consumers use React, Vue, and Svelte. You cannot force any CSS framework on them.

Recommendation: CSS Modules (or Vanilla CSS)

CSS Modules produce standard CSS with no runtime dependencies and no framework lock-in. Consumers of your component library do not need Tailwind, Styled Components, or any CSS tooling — they get scoped CSS that works everywhere. This is the most portable styling approach for shared libraries.

Scenario 3: Your React app has heavy dynamic styling — buttons change color based on 5 variant props, cards animate based on scroll position, and themes switch in real-time based on user preferences.

Recommendation: CSS Modules + CSS Variables (or Tailwind with CSS variables)

Use CSS variables for dynamic values (colors that change with theme, animation states) and CSS Modules for static scoped styles. This gives you dynamic styling without runtime CSS-in-JS overhead. Tailwind with style attributes for CSS variables also works well. Avoid Styled Components for new projects due to runtime performance costs.

Common Misconceptions

  • "Tailwind is just inline styles" — Tailwind utilities are CSS classes, not inline styles. They support pseudo-classes (:hover, :focus), responsive breakpoints (md:, lg:), and media queries (dark:, print:) — none of which inline styles can do.
  • "CSS-in-JS is dead" — Runtime CSS-in-JS (Styled Components, Emotion) has fallen out of favor, but zero-runtime CSS-in-JS (Vanilla Extract, Panda CSS, StyleX) is actively growing. The concern is runtime overhead, not the CSS-in-JS concept itself.
  • "You cannot build a design system with Tailwind" — shadcn/ui, Headless UI, and Radix UI all use Tailwind for styling. Tailwind's config file IS your design system — it defines spacing, colors, typography, and breakpoints.
  • "CSS Modules are outdated" — CSS Modules are used by Next.js, Vite, and every major build tool out of the box. They provide scoping with zero runtime cost and no vendor lock-in. "Simple and boring" is not the same as "outdated."

Real Migration Stories

GitHub: CSS to Tailwind (Primer) — GitHub's Primer design system adopted utility-class patterns inspired by Tailwind for their CSS framework. They found that utility classes reduced CSS bundle size because common patterns (flexbox, spacing, colors) were shared across components instead of duplicated.

Airbnb: CSS-in-JS to Static CSS — Airbnb publicly shared their concerns about runtime CSS-in-JS performance, contributing to the broader industry shift away from Styled Components toward zero-runtime alternatives. Their experience highlighted that dynamic style injection causes measurable performance issues at scale.

Quiz

1. Why does Tailwind produce smaller CSS than CSS Modules for large applications?

Tailwind utility classes are shared across components — flex, p-4, and text-sm are emitted once regardless of how many components use them. CSS Modules duplicate similar styles across every component's scoped stylesheet.

2. What is the runtime performance cost of Styled Components?

Styled Components adds ~15 KB of JavaScript runtime, parses CSS template literals at runtime, generates unique class names, and injects <style> tags into the DOM. Dynamic styles create new CSS classes on every React re-render, causing CSSOM manipulation and potential layout shifts.

3. How do CSS Modules prevent naming collisions?

The build tool (Vite, Webpack) transforms each class name in a .module.css file into a unique hash. .card becomes ._card_1a2b3c. The component imports a mapping object, so styles.card resolves to the hashed name. Two components with .card classes produce different hashed names.

4. What modern CSS features reduce the need for CSS-in-JS or utility frameworks?

CSS nesting (write nested selectors like Sass), :has() (parent selector), container queries (@container), cascade layers (@layer for specificity management), and @scope (native CSS scoping) address problems that previously required tooling.

5. Why did the React team recommend against runtime CSS-in-JS?

Runtime CSS-in-JS libraries inject styles during rendering, which conflicts with React Server Components (which run on the server where DOM manipulation is not available) and causes performance issues during SSR hydration (Flash of Unstyled Content). Zero-runtime or build-time CSS solutions are preferred.

One-Liner Summary

Tailwind is the fastest way to build consistent UIs with the smallest CSS output, CSS Modules offer scoped standard CSS with zero runtime, Styled Components is legacy for most new projects, and vanilla CSS with modern features is increasingly viable.

"What I cannot create, I do not understand." — Richard Feynman