ReactCSS Modules

CSS Modules

CSS Modules solve the biggest pain point in traditional CSS: global scope. In a plain CSS file every class name is visible to every component in the application. In a CSS Module each class name is automatically transformed into a unique, locally-scoped identifier at build time, making naming collisions structurally impossible.

CSS Modules are a build-tool convention, not a library. Vite, Create React App, and Next.js all support them out of the box — no extra packages required. Any file ending in .module.css (or .module.scss with Sass installed) is treated as a CSS Module.

How Local Scoping Works

When the bundler processes a .module.css file it renames every class selector to a generated string that is unique to that file. The component that imports the file receives a JavaScript object that maps your original class names to those generated names.

CSS
/* Button.module.css */
.button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 18px;
  font-size: 0.875rem;
  font-weight: 600;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.15s ease, transform 0.1s ease;
  border: none;
}

.primary {
  background: #0070f3;
  color: #fff;
}

.primary:hover {
  background: #0051a2;
}

.secondary {
  background: transparent;
  color: #0070f3;
  border: 1.5px solid #0070f3;
}

.secondary:hover {
  background: #eff6ff;
}

.button:active {
  transform: scale(0.97);
}

TSX
// Button.tsx
import styles from './Button.module.css'

interface ButtonProps {
  variant?: 'primary' | 'secondary'
  children: React.ReactNode
  onClick?: () => void
}

export function Button({ variant = 'primary', children, onClick }: ButtonProps) {
  return (
    <button
      className={`${styles.button} ${variant === 'secondary' ? styles.secondary : styles.primary}`}
      onClick={onClick}
    >
      {children}
    </button>
  )
}

At build time, styles.button might resolve to "Button_button__xK2qP" and styles.primary to "Button_primary__3mRt7". These names are unique to Button.module.css — no other component can accidentally apply or override them.

Composing Classes with clsx

Template literals for combining classes get messy quickly. The clsx library (and the popular cn wrapper from Shadcn) provides a clean API for conditional class composition. Install it once and use it everywhere:

Bash
npm install clsx

TSX
import clsx from 'clsx'
import styles from './Button.module.css'

interface ButtonProps {
  variant?: 'primary' | 'secondary'
  disabled?: boolean
  fullWidth?: boolean
  children: React.ReactNode
}

export function Button({ variant = 'primary', disabled, fullWidth, children }: ButtonProps) {
  return (
    <button
      disabled={disabled}
      className={clsx(
        styles.button,
        variant === 'primary' && styles.primary,
        variant === 'secondary' && styles.secondary,
        disabled && styles.disabled,
        fullWidth && styles.fullWidth,
      )}
    >
      {children}
    </button>
  )
}
Tip
clsx also accepts objects: `clsx({ [styles.primary]: variant === 'primary' })`. Both forms are common — pick the one that reads more clearly for your use case.
The :global() Escape Hatch

Sometimes you need to target a class name that is not scoped to your module — for example, a class injected by a third-party library or a global utility class from a design system. Use :global() to opt out of scoping for a specific selector:

CSS
/* Card.module.css */
.card {
  padding: 16px;
  border-radius: 8px;
  box-shadow: 0 1px 3px rgba(0,0,0,0.12);
}

/* Target the globally-scoped class from a markdown renderer */
.card :global(.prose) {
  line-height: 1.75;
}

/* :global alone makes the rule fully global */
:global(.sr-only) {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
}
CSS Modules Composition with composes

CSS Modules has a built-in composes keyword that lets a class inherit styles from another class, even from a different module file. This is useful for building a style hierarchy without duplicating CSS:

CSS
/* base.module.css */
.baseButton {
  padding: 8px 16px;
  border-radius: 4px;
  font-weight: 600;
  border: none;
  cursor: pointer;
}

/* PrimaryButton.module.css */
.primaryButton {
  composes: baseButton from './base.module.css';
  background: #0070f3;
  color: white;
}

/* DangerButton.module.css */
.dangerButton {
  composes: baseButton from './base.module.css';
  background: #dc2626;
  color: white;
}
Note
`composes` applies both class names to the element — it does not copy the CSS properties. The generated `className` string will contain multiple hashed names. This means the element genuinely has two classes, and specificity rules apply normally.
TypeScript Support

TypeScript does not know the shape of a CSS Module import by default. The simplest fix is adding a declaration file to your project:

TS
// src/types/css-modules.d.ts
declare module '*.module.css' {
  const classes: Record<string, string>
  export default classes
}

declare module '*.module.scss' {
  const classes: Record<string, string>
  export default classes
}

For stronger typing — where TypeScript knows the exact class names your module exports — install typescript-plugin-css-modules. It reads the actual .module.css file and generates precise type information:

Bash
npm install --save-dev typescript-plugin-css-modules

JSON
// tsconfig.json
{
  "compilerOptions": {
    "plugins": [{ "name": "typescript-plugin-css-modules" }]
  }
}

With the plugin, styles.typo will produce a TypeScript error if there is no .typo class in your CSS Module file — a powerful safety net.

Benefits & Limitations

Aspect

CSS Modules

Naming collisions

Impossible — scoped at build time

Runtime overhead

None — pure build-time transformation

SSR support

Perfect — static class strings

CSS features

Full CSS, PostCSS, Sass, nesting

Dynamic styles

Awkward — must toggle classes or use CSS custom properties

Co-location

Separate file (same folder convention)

Tooling

Built into Vite, CRA, Next.js

Dynamic Styles: The Right Pattern

When you need a style that depends on a runtime value (e.g., an animation progress percentage or a user-chosen color), combine a CSS Module class with a CSS custom property via the style prop. The class provides the static structure; the custom property carries the dynamic value:

CSS
/* ProgressBar.module.css */
.track {
  height: 8px;
  border-radius: 4px;
  background: #e5e7eb;
  overflow: hidden;
}

.fill {
  height: 100%;
  background: var(--progress-color, #0070f3);
  width: var(--progress-value, 0%);
  transition: width 0.3s ease;
  border-radius: 4px;
}

TSX
import styles from './ProgressBar.module.css'

interface ProgressBarProps {
  value: number        // 0–100
  color?: string
}

export function ProgressBar({ value, color = '#0070f3' }: ProgressBarProps) {
  return (
    <div className={styles.track}>
      <div
        className={styles.fill}
        style={{
          '--progress-value': `${value}%`,
          '--progress-color': color,
        } as React.CSSProperties}
      />
    </div>
  )
}
  • Use CSS Modules as your default styling approach in new React projects that need zero runtime overhead

  • Name files ComponentName.module.css in the same folder as the component

  • Use clsx for conditional class composition instead of string concatenation

  • Use :global() sparingly — only when targeting third-party or legacy class names

  • Use CSS custom properties for the few truly dynamic values, not class toggling

  • Add typescript-plugin-css-modules for full type safety on class names

Warning
Do not use CSS Modules for truly global styles like CSS resets, font-face declarations, or design tokens. Import those from a regular (non-module) CSS file in your root layout instead.