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.
/* 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);
}// 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:
npm install clsx
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>
)
}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:
/* 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:
/* 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;
}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:
// 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:
npm install --save-dev typescript-plugin-css-modules
// 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:
/* 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;
}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.cssin the same folder as the componentUse
clsxfor conditional class composition instead of string concatenationUse
:global()sparingly — only when targeting third-party or legacy class namesUse CSS custom properties for the few truly dynamic values, not class toggling
Add
typescript-plugin-css-modulesfor full type safety on class names