React CSS Modules and Styled-Components Guide (2026)

React has no built-in opinion on styling. CSS Modules, styled-components, Emotion, Tailwind and plain CSS all have legitimate use cases. This guide covers CSS Modules and styled-components in depth — the two most common non-Tailwind approaches — then compares all major options so you can choose the right tool for your project.

CSS Modules

CSS Modules scope class names to the component by transforming them to unique identifiers at build time. No runtime overhead, no naming conflicts, full CSS syntax support:

/* Button.module.css */
.button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 20px;
  border: none;
  border-radius: 50px;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.15s, box-shadow 0.15s;
}

.button:hover {
  transform: translateY(-2px);
}

.primary {
  background: linear-gradient(135deg, #6366f1, #22d3ee);
  color: white;
}

.secondary {
  background: transparent;
  border: 1px solid rgba(99, 102, 241, 0.4);
  color: #6366f1;
}

.danger {
  background: #ef4444;
  color: white;
}

.sm { padding: 6px 14px; font-size: 0.85rem; }
.md { padding: 10px 20px; font-size: 1rem; }
.lg { padding: 14px 28px; font-size: 1.1rem; }

.disabled {
  opacity: 0.5;
  cursor: not-allowed;
  pointer-events: none;
}
// Button.tsx
import styles from './Button.module.css'
import clsx from 'clsx'   // npm install clsx

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  loading?: boolean
}

export function Button({
  variant = 'primary',
  size = 'md',
  loading = false,
  disabled,
  className,
  children,
  ...props
}: ButtonProps) {
  return (
    <button
      className={clsx(
        styles.button,
        styles[variant],
        styles[size],
        disabled && styles.disabled,
        className   // Allow callers to add extra classes
      )}
      disabled={disabled || loading}
      {...props}
    >
      {loading && <Spinner className={styles.spinner} />}
      {children}
    </button>
  )
}

CSS Modules: Advanced Patterns

/* Composition — reuse styles from another module */
.cardBase {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 24px;
}

.featuredCard {
  composes: cardBase;   /* Inherits all cardBase styles */
  border-color: var(--purple);
  box-shadow: 0 0 24px rgba(99, 102, 241, 0.15);
}

/* Compose from another file */
.button {
  composes: base from './shared/base.module.css';
  background: #6366f1;
}

/* Global styles from a module */
:global(.recharts-tooltip-wrapper) {
  /* Style third-party component from inside a CSS Module */
  border-radius: 8px !important;
}

/* CSS variables scoped to component */
.theme {
  --card-bg: #0d1424;
  --card-border: rgba(99, 102, 241, 0.2);
}
.card {
  background: var(--card-bg);
  border: 1px solid var(--card-border);
}
// TypeScript: typed CSS Modules
// Install: npm install typescript-plugin-css-modules
// tsconfig.json: "plugins": [{ "name": "typescript-plugin-css-modules" }]

// Now styles.button is type-safe — TypeScript knows the class names
import styles from './Button.module.css'
// styles.button ✓  styles.nonExistent ✗ (TypeScript error)

styled-components

npm install styled-components
npm install --save-dev @types/styled-components
import styled, { css } from 'styled-components'

// Basic styled component — template literal CSS
const Button = styled.button<{ variant: 'primary' | 'secondary'; size?: 'sm' | 'md' | 'lg' }>`
  display: inline-flex;
  align-items: center;
  gap: 8px;
  border: none;
  border-radius: 50px;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.15s;

  &:hover {
    transform: translateY(-2px);
  }

  /* Dynamic styles based on props */
  ${({ variant }) =>
    variant === 'primary' && css`
      background: linear-gradient(135deg, #6366f1, #22d3ee);
      color: white;
    `}

  ${({ variant }) =>
    variant === 'secondary' && css`
      background: transparent;
      border: 1px solid rgba(99, 102, 241, 0.4);
      color: #6366f1;
    `}

  ${({ size = 'md' }) =>
    ({
      sm: css`padding: 6px 14px; font-size: 0.85rem;`,
      md: css`padding: 10px 20px; font-size: 1rem;`,
      lg: css`padding: 14px 28px; font-size: 1.1rem;`,
    }[size])}
`

// Extend an existing styled component
const DangerButton = styled(Button)`
  background: #ef4444;
  color: white;
`

// Style a third-party component (must accept className)
const StyledLink = styled(RouterLink)`
  color: #6366f1;
  text-decoration: none;
  &:hover { text-decoration: underline; }
`

// Usage
<Button variant="primary" size="lg">Save</Button>
<DangerButton variant="primary">Delete</DangerButton>

styled-components: Theming and Dynamic Styles

import { ThemeProvider, DefaultTheme } from 'styled-components'

// Declare theme shape
declare module 'styled-components' {
  export interface DefaultTheme {
    colors: {
      primary: string
      surface: string
      text: string
    }
    radii: { sm: string; md: string; lg: string }
  }
}

const darkTheme: DefaultTheme = {
  colors: { primary: '#6366f1', surface: '#0d1424', text: '#e2e8f0' },
  radii: { sm: '6px', md: '12px', lg: '20px' },
}

const lightTheme: DefaultTheme = {
  colors: { primary: '#4f46e5', surface: '#ffffff', text: '#0f172a' },
  radii: { sm: '6px', md: '12px', lg: '20px' },
}

// Wrap app with ThemeProvider
function App() {
  const [isDark, setIsDark] = useState(true)
  return (
    <ThemeProvider theme={isDark ? darkTheme : lightTheme}>
      <GlobalStyles />
      <Router />
    </ThemeProvider>
  )
}

// Components access theme via props
const Card = styled.div`
  background: ${({ theme }) => theme.colors.surface};
  color: ${({ theme }) => theme.colors.text};
  border-radius: ${({ theme }) => theme.radii.md};
  padding: 24px;
`

// Global styles
import { createGlobalStyle } from 'styled-components'

const GlobalStyles = createGlobalStyle`
  *, *::before, *::after { box-sizing: border-box; }
  body {
    background: ${({ theme }) => theme.colors.surface};
    color: ${({ theme }) => theme.colors.text};
    margin: 0;
  }
`

Emotion

npm install @emotion/react @emotion/styled
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react'
import styled from '@emotion/styled'

// css prop — inline styles with full CSS power
function Component() {
  return (
    <div
      css={css`
        background: #0d1424;
        padding: 24px;
        border-radius: 12px;

        &:hover {
          box-shadow: 0 8px 32px rgba(99, 102, 241, 0.2);
        }
      `}
    >
      Content
    </div>
  )
}

// Dynamic css with variables
const cardStyle = (isHighlighted: boolean) => css`
  background: #0d1424;
  border: 1px solid ${isHighlighted ? '#6366f1' : 'rgba(99,102,241,0.2)'};
  transition: border-color 0.2s;
`

// Emotion styled (identical API to styled-components)
const Button = styled.button`
  background: #6366f1;
  color: white;
  padding: 10px 20px;
  border-radius: 50px;
`

Approach Comparison

Approach Best For Trade-offs
CSS ModulesTraditional CSS workflows, zero runtimeNo dynamic styles, separate file per component
styled-componentsComponent libraries, theming, dynamic stylesRuntime overhead, hydration issues in SSR
Emotioncss prop flexibility, similar to styled-componentsRuntime overhead, JSX pragma required for css prop
Tailwind CSSRapid prototyping, design systemsVerbose JSX, learning curve for class names
Vanilla ExtractType-safe, zero-runtime CSS-in-TSMore boilerplate than styled-components

Next.js Considerations

styled-components in Next.js App Router: styled-components uses runtime CSS injection which conflicts with React Server Components. You need to add 'use client' to every styled-component file, and configure the styled-components compiler option in next.config.ts. For new Next.js App Router projects, CSS Modules or Tailwind are the safer choices — they have zero runtime and work with RSC without any special configuration.
// next.config.ts — enable styled-components compiler
const nextConfig = {
  compiler: {
    styledComponents: true,   // Enables SSR, display names, smaller bundles
  },
}

// With CSS Modules in Next.js — just works, no config needed
import styles from './page.module.css'

export default function Page() {
  return <main className={styles.main}>Content</main>
}