CSS-in-JS vs CSS Modules vs Tailwind: Choosing the Right Tool

A comprehensive comparison of modern CSS solutions and guidance on when to use each approach

ByYour Name
8 min read
CSSStylingReactFrontendDeveloper Experience

CSS-in-JS vs CSS Modules vs Tailwind: Choosing the Right Tool

Styling in modern web development has never been more complex—or more exciting. With so many approaches available, choosing the right styling solution can feel overwhelming. After working with all the major approaches across different projects, I want to share my thoughts on when and why to choose each one.

The Landscape of Modern CSS

Let's start by understanding what each approach brings to the table:

Traditional CSS

The foundation we all know, but with modern tooling:

/* styles.css */
.button {
  background-color: #3b82f6;
  color: white;
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
  border: none;
  cursor: pointer;
}

.button:hover {
  background-color: #2563eb;
}

.button--large {
  padding: 0.75rem 1.5rem;
  font-size: 1.125rem;
}

CSS Modules

Scoped CSS with familiar syntax:

/* Button.module.css */
.button {
  background-color: #3b82f6;
  color: white;
  padding: 0.5rem 1rem;
  border-radius: 0.375rem;
  border: none;
  cursor: pointer;
}

.large {
  padding: 0.75rem 1.5rem;
  font-size: 1.125rem;
}
// Button.jsx
import styles from './Button.module.css';

export function Button({ size = 'normal', children }) {
  return (
    <button className={`${styles.button} ${size === 'large' ? styles.large : ''}`}>
      {children}
    </button>
  );
}

CSS-in-JS (Styled Components)

Styles written in JavaScript:

import styled from 'styled-components';

const StyledButton = styled.button`
  background-color: #3b82f6;
  color: white;
  padding: ${props => props.size === 'large' ? '0.75rem 1.5rem' : '0.5rem 1rem'};
  border-radius: 0.375rem;
  border: none;
  cursor: pointer;

  &:hover {
    background-color: #2563eb;
  }
`;

export function Button({ size, children }) {
  return <StyledButton size={size}>{children}</StyledButton>;
}

Tailwind CSS

Utility-first approach:

export function Button({ size = 'normal', children }) {
  const baseClasses = 'bg-blue-500 text-white rounded-md border-none cursor-pointer hover:bg-blue-600';
  const sizeClasses = size === 'large' ? 'px-6 py-3 text-lg' : 'px-4 py-2';
  
  return (
    <button className={`${baseClasses} ${sizeClasses}`}>
      {children}
    </button>
  );
}

Deep Dive: CSS Modules

Pros

  • Scoped by default: No global CSS conflicts
  • Familiar syntax: Regular CSS with automatic scoping
  • Great tooling: Excellent IDE support and debugging
  • Compose patterns: Can import and compose other styles
/* Theme.module.css */
.primaryColor {
  color: #3b82f6;
}

/* Button.module.css */
.button {
  composes: primaryColor from './Theme.module.css';
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 0.375rem;
}

Cons

  • Manual class management: Still need to conditionally apply classes
  • Build step required: Needs bundler configuration
  • Limited dynamic styling: Hard to style based on runtime values

When to Use CSS Modules

Perfect for:

  • Component libraries where you want familiar CSS syntax
  • Teams transitioning from traditional CSS
  • Projects that need precise control over generated class names
  • When you want scoping without learning new syntax

Deep Dive: CSS-in-JS

Different Flavors

Styled Components (Runtime)

const Button = styled.button`
  background: ${props => props.primary ? '#3b82f6' : '#6b7280'};
  color: white;
  padding: 0.5rem 1rem;
  
  ${props => props.size === 'large' && css`
    padding: 0.75rem 1.5rem;
    font-size: 1.125rem;
  `}
`;

Emotion (Runtime)

const buttonStyles = (theme, size) => css`
  background: ${theme.colors.primary};
  color: white;
  padding: ${size === 'large' ? '0.75rem 1.5rem' : '0.5rem 1rem'};
`;

export function Button({ size, ...props }) {
  return <button css={buttonStyles(useTheme(), size)} {...props} />;
}

Linaria (Zero-runtime)

import { styled } from '@linaria/react';

const StyledButton = styled.button`
  background: #3b82f6;
  color: white;
  padding: 0.5rem 1rem;
`;

Pros

  • Dynamic styling: Easy to style based on props and state
  • Automatic scoping: No naming conflicts
  • Theme integration: Excellent theming support
  • Developer experience: Great TypeScript integration
// Advanced example with theming
const Button = styled.button<{ variant: 'primary' | 'secondary'; size: 'sm' | 'lg' }>`
  padding: ${({ size }) => size === 'lg' ? '12px 24px' : '8px 16px'};
  border-radius: 6px;
  border: none;
  cursor: pointer;
  
  ${({ theme, variant }) => {
    switch (variant) {
      case 'primary':
        return css`
          background: ${theme.colors.primary};
          color: ${theme.colors.primaryText};
          &:hover {
            background: ${theme.colors.primaryHover};
          }
        `;
      case 'secondary':
        return css`
          background: ${theme.colors.secondary};
          color: ${theme.colors.secondaryText};
          &:hover {
            background: ${theme.colors.secondaryHover};
          }
        `;
    }
  }}
`;

Cons

  • Runtime overhead: Styles computed at runtime (except zero-runtime solutions)
  • Bundle size: Can increase JavaScript bundle size
  • Learning curve: Different mental model from traditional CSS
  • Debugging complexity: Generated class names can be hard to debug

When to Use CSS-in-JS

Perfect for:

  • Highly dynamic UIs with lots of conditional styling
  • Component libraries that need extensive theming
  • Teams comfortable with JavaScript-first approaches
  • Applications where the styling logic is complex

Deep Dive: Tailwind CSS

The Utility-First Philosophy

// Instead of semantic classes
<button className="primary-button large-button">

// Use utility classes
<button className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded">

Advanced Patterns

Extracting Components

// Button.jsx
export function Button({ variant = 'primary', size = 'md', children, ...props }) {
  const baseClasses = 'font-semibold rounded transition-colors duration-200';
  
  const variantClasses = {
    primary: 'bg-blue-500 hover:bg-blue-600 text-white',
    secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
    outline: 'border-2 border-blue-500 text-blue-500 hover:bg-blue-50'
  };
  
  const sizeClasses = {
    sm: 'py-1 px-3 text-sm',
    md: 'py-2 px-4',
    lg: 'py-3 px-6 text-lg'
  };
  
  return (
    <button 
      className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`}
      {...props}
    >
      {children}
    </button>
  );
}

Using with CSS-in-JS

import { styled } from 'styled-components';

const StyledButton = styled.button.attrs(props => ({
  className: `
    px-4 py-2 rounded font-semibold transition-colors
    ${props.variant === 'primary' ? 'bg-blue-500 text-white hover:bg-blue-600' : ''}
    ${props.variant === 'secondary' ? 'bg-gray-200 text-gray-800 hover:bg-gray-300' : ''}
  `
}))`
  /* Additional custom styles if needed */
`;

Custom Utilities

/* In your CSS file or Tailwind config */
@layer utilities {
  .btn-primary {
    @apply bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded;
  }
  
  .btn-secondary {
    @apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold py-2 px-4 rounded;
  }
}

Pros

  • Rapid development: No context switching between CSS and HTML
  • Consistent design: Built-in design system
  • Tiny CSS bundle: Only includes utilities you use
  • Responsive design: Excellent responsive utilities
// Responsive design made easy
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  <div className="bg-white p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow">
    Card content
  </div>
</div>

Cons

  • Learning curve: Need to memorize utility class names
  • Verbose HTML: Class names can get very long
  • Design limitations: Constrained to predefined utilities (though customizable)
  • Maintenance: Refactoring can be tedious without proper tooling

When to Use Tailwind

Perfect for:

  • Rapid prototyping and development
  • Teams that want a consistent design system out of the box
  • Projects where design flexibility within constraints is desired
  • Developers who prefer staying in the markup

Performance Considerations

Bundle Size Impact

// Typical bundle sizes for a medium-sized app

// CSS Modules: 
// CSS: ~15-25KB (gzipped)
// JS: No overhead

// Styled Components:
// CSS: ~5-10KB (gzipped, styles in JS)
// JS: +13KB for styled-components library

// Tailwind:
// CSS: ~8-15KB (gzipped, only used utilities)
// JS: No overhead

// Traditional CSS:
// CSS: ~30-50KB (gzipped, often includes unused styles)
// JS: No overhead

Runtime Performance

  • CSS Modules: ✅ No runtime overhead
  • Tailwind: ✅ No runtime overhead
  • CSS-in-JS (runtime): ⚠️ Style computation on every render
  • CSS-in-JS (zero-runtime): ✅ No runtime overhead

Decision Framework

Choose CSS Modules When:

  • Your team is comfortable with traditional CSS
  • You need precise control over generated styles
  • Performance is critical and you want zero runtime overhead
  • You're building a component library that others will consume

Choose CSS-in-JS When:

  • You have complex, dynamic styling requirements
  • Theming is a major concern
  • Your team prefers JavaScript-first approaches
  • You're building a highly interactive application

Choose Tailwind When:

  • You want rapid development with a consistent design system
  • Your team values utility-first methodology
  • You're building content-heavy sites or admin interfaces
  • Design consistency is more important than pixel-perfect control

Choose Traditional CSS When:

  • You're working on a simple project
  • Your team is small and coordination isn't a major issue
  • You need maximum browser compatibility
  • You're working with a design system that maps well to semantic class names

Hybrid Approaches

You don't have to choose just one! Many successful projects combine approaches:

Tailwind + CSS Modules

/* Button.module.css */
.button {
  @apply px-4 py-2 rounded font-semibold transition-colors;
}

.primary {
  @apply bg-blue-500 text-white hover:bg-blue-600;
}

.secondary {
  @apply bg-gray-200 text-gray-800 hover:bg-gray-300;
}

CSS-in-JS + Tailwind

const Button = styled.button.attrs(props => ({
  className: props.className
}))`
  ${tw`px-4 py-2 rounded font-semibold transition-colors`}
  
  ${props => props.variant === 'primary' && tw`bg-blue-500 text-white hover:bg-blue-600`}
  ${props => props.variant === 'gradient' && css`
    background: linear-gradient(45deg, #3b82f6, #8b5cf6);
  `}
`;

My Recommendations

Based on my experience across different project types:

For Startups/MVPs: Tailwind CSS

  • Rapid development velocity
  • Built-in design system
  • Easy to iterate quickly

For Component Libraries: CSS Modules

  • Familiar CSS syntax for contributors
  • Precise control over styling
  • Great IDE support

For Design-Heavy Applications: CSS-in-JS

  • Complex theming requirements
  • Lots of dynamic, interactive styles
  • Strong TypeScript integration needs

For Content Sites: Tailwind + Custom CSS

  • Utility classes for layout and spacing
  • Custom CSS for unique design elements
  • Great for marketing sites and blogs

Conclusion

There's no universally "best" styling solution—only the one that best fits your project's constraints and your team's preferences. The key is understanding the trade-offs and choosing intentionally.

Consider:

  • Team expertise and learning curve tolerance
  • Project requirements (performance, theming, dynamic styles)
  • Maintenance burden over time
  • Development velocity needs

Remember that you can always migrate or adopt hybrid approaches as your project evolves. The most important thing is to choose something that helps your team ship great user experiences efficiently.


What styling approach has worked best for your projects? I'd love to hear about your experiences and any hybrid approaches you've found successful!