React 71 ⚛️ CSS Modules in React
CSS Modules solve the oldest problem in CSS at scale: every class name is global. A .button class defined for one component applies to every element with that class across the entire application, and the only defenses are naming conventions like BEM or heavy-handed specificity. CSS Modules change this by making class names local by default. When you import a .module.css file in a React component, you receive an object whose keys are your original class names and whose values are the generated unique names—something like Button_button__2Xdp8 . The CSS in that file is transformed at build time, and the generated class names are guaranteed not to collide with any other module.
The mechanism is transparent to the developer. You write normal CSS, use normal class names, and apply them through the imported object. The build tool—whether Vite, Create React App, or Next.js—does the transformation. This makes CSS Modules a low-friction step up from plain CSS: you keep the separation of styles and markup, you keep the full power of CSS (pseudo-classes, media queries, preprocessors), and you gain scoping without adopting a runtime CSS-in-JS library .
This chapter covers how CSS Modules work in React, the syntax for importing and applying class names, the patterns for conditional and composed classes, the :global escape hatch, and the tooling considerations for Vite, CRA, Next.js, and TypeScript.
Key point: A CSS Module is a CSS file named with the .module.css extension; importing it returns an object mapping your original class names to generated unique names, and applying styles.className in className scopes the styles to that component without global collisions.
Why CSS Modules exist
The global namespace problem. In plain CSS, every selector you write is available to every element in the document. A component that defines .card and a different component that also defines .card will conflict, and the winner is determined by source order and specificity, not by intent. CSS Modules eliminate this by transforming class names into unique identifiers at build time .
The naming discipline problem. Without scoping, teams rely on conventions like BEM to avoid collisions. These conventions work until they do not—a new developer joins and uses the wrong prefix, a third-party library ships a .button class, or a refactor renames a component without renaming its CSS. CSS Modules make the scoping mechanical rather than conventional .
The dead code problem. Global CSS is difficult to tree-shake. If a class is defined but never used, the bundler cannot prove it is safe to remove because any element anywhere might reference it. CSS Modules co-locate styles with components and make the dependency explicit: if the component is not imported, its styles are not bundled .
The preprocessor problem. CSS Modules work with Sass, Less, and Stylus by changing the extension: Button.module.scss instead of Button.module.css. The module transformation applies after the preprocessor runs, so you keep variables, nesting, and mixins while gaining scoping .
The TypeScript problem. TypeScript does not know what a .module.css file exports by default. Without a declaration, importing the styles object produces a “Cannot find module” error. The fix is a small .d.ts file that declares the module shape, or a build-tool integration that generates types automatically .
a. The basic pattern: file, import, apply
The pattern has three parts. First, create a CSS file with the .module.css extension. Second, import it in the component. Third, apply class names through the imported object.
/* Button.module.css */
.button {
padding: 8px 16px;
border: none;
border-radius: 4px;
}
.primary {
background-color: #007bff;
color: white;
}
import styles from './Button.module.css'
function Button({ primary, children }) {
return (
<button className={`${styles.button} ${primary ? styles.primary : ''}`}>
{children}
</button>
)
}
The styles object has keys button and primary. The values are the generated names—something like Button_button__aB3dE and Button_primary__xY9zW . When React renders the button, the className prop contains those generated names. The browser applies the styles because the CSS file, after transformation, contains the same generated names.
For class names with hyphens, use bracket notation: styles['button-primary'] . This is because JavaScript dot notation does not allow hyphens in property names.
If you want to mix module classes with a global class, concatenate them in the string: className={`${styles.button} my-global-class`} .
b. Conditional classes and composition
The template literal approach works for simple cases, but it becomes noisy with multiple conditions. The classnames library (or clsx) is the standard solution:
import classNames from 'classnames'
import styles from './Card.module.css'
function Card({ highlighted, size, children }) {
return (
<div className={classNames({
[styles.card]: true,
[styles.highlighted]: highlighted,
[styles.small]: size === 'small',
[styles.large]: size === 'large'
})}>
{children}
</div>
)
}
This is more readable than nested ternaries and template literals . The classnames library also filters out falsey values, so you do not end up with empty strings in the class list.
CSS Modules also support composes within the CSS file itself. A class can inherit the styles of another class:
.base {
border: 1px solid #ddd;
border-radius: 4px;
}
.card {
composes: base;
padding: 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
The card class now includes all styles from base plus its own. This is not the same as a CSS @extend or a Sass mixin—it is a build-time composition that copies the declarations into the composed class, and it works across files if you reference the module path . Composition is useful for variants and shared foundations, but it can obscure where styles come from if overused.
c. Global styles, preprocessors, and tooling
The :global pseudo-class escapes the local scoping. Any selector wrapped in :global() is emitted as-is and is available globally:
:global(.heading) {
font-size: 24px;
}
You can also use :global inside a local selector to target a global child: .card :global(.title) { font-weight: bold; } . This is an escape hatch. If you find yourself using it frequently, the styles probably belong in a global stylesheet rather than a module.
Preprocessors work by changing the extension. Button.module.scss produces a module that supports Sass syntax. The build tool applies the preprocessor first, then the module transformation . Vite, Create React App, and Next.js all support this without additional configuration beyond installing the preprocessor itself.
For TypeScript, the default behavior is a missing module error. Create a declaration file, typically src/types/assets.d.ts, with the content declare module '*.module.css'; . This tells TypeScript to treat the import as an object with string keys. For stricter typing, tools like typed-scss-modules can generate a .d.ts file per module with the actual class names, so that a typo like styles.buton is caught at compile time rather than producing undefined in the class list .
In Vite, the CSS Modules configuration is optional but available under css.modules in vite.config.js. The localsConvention option controls whether kebab-case class names are accessible via camelCase keys, and generateScopedName controls the format of the generated names . In Next.js, CSS Modules are supported by default for files ending in .module.css, and the production build code-splits them so that each route loads only the styles it needs .
Complete Example Session
The following session builds a reusable Card component with CSS Modules, conditional variants, and composition.
/* Card.module.css */
.base {
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
transition: box-shadow 0.3s ease;
}
.card {
composes: base;
background: white;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.header {
padding: 12px 16px;
border-bottom: 1px solid #eee;
font-weight: 600;
}
.content {
padding: 16px;
}
.highlighted {
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.compact .content {
padding: 8px;
}
:global(.card-title) {
margin: 0;
font-size: 18px;
}
import classNames from 'classnames'
import styles from './Card.module.css'
function Card({ title, highlighted, compact, children }) {
return (
<div className={classNames(styles.card, {
[styles.highlighted]: highlighted,
[styles.compact]: compact
})}>
{title && (
<div className={styles.header}>
<h3 className="card-title">{title}</h3>
</div>
)}
<div className={styles.content}>
{children}
</div>
</div>
)
}
export default Card
import Card from './Card'
function App() {
return (
<div>
<Card title="Default">Default card content</Card>
<Card title="Highlighted" highlighted>Highlighted card</Card>
<Card title="Compact" compact>Compact card</Card>
</div>
)
}
The CSS file defines a base class, composes it into card, and provides variant classes for highlighting and compacting. The JSX uses classnames to conditionally apply highlighted and compact. The header contains an h3 with the global card-title class, demonstrating the :global escape hatch for a style that should apply regardless of which module renders it. The generated class names in the browser will be unique to this module, so a .card class in another component cannot interfere.
Quick Reference
File Naming and Import
| Item | Example |
|---|---|
| CSS Module file | Button.module.css |
| Preprocessor module | Button.module.scss |
| Import | import styles from './Button.module.css' |
| Apply class | className={styles.button} |
| Hyphenated key | styles['button-primary'] |
Class Application Patterns
| Pattern | Code |
|---|---|
| Single class | className={styles.button} |
| Multiple classes | className={`${styles.a} ${styles.b}`} |
| Conditional | className={cond ? styles.a : styles.b} |
With classnames | classNames({ [styles.a]: cond }) |
| Mix with global | className={`${styles.a} global`} |
Composition and Global
| Feature | Syntax |
|---|---|
| Compose class | composes: base; |
| Global selector | :global(.name) { ... } |
| Global child in local | .local :global(.child) { ... } |
Tooling Notes
| Tool | Behavior |
|---|---|
| Vite | Automatic for .module.css |
| Create React App | Automatic for .module.css |
| Next.js | Automatic for .module.css |
| TypeScript | Needs declare module '*.module.css' |
Best Practices
✅ Do This:
import styles from './Button.module.css' // ✅ module import
className={classNames(styles.button, { [styles.primary]: primary })} // ✅ clean conditional
composes: base; // ✅ CSS composition
declare module '*.module.css'; // ✅ TypeScript declaration
❌ Don’t Do This:
import './Button.module.css' // ❌ no object returned
className="button" // ❌ global class, not scoped
// use :global for everything // ❌ defeats the purpose
// rely on string concatenation with undefined // ❌ produces "undefined" in class
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
styles is undefined | Imported without from, or no declaration | import styles from ...; add .d.ts |
| Class not applied | Used plain string instead of styles.name | Reference the imported object |
| Hyphenated class undefined | Dot notation on hyphenated key | styles['my-class'] |
| TypeScript module error | No declaration file | declare module '*.module.css' |
| Styles leak globally | Used :global unnecessarily | Keep selectors local |
| Composition not working | composes references a class in another file without path | Use composes: base from './base.module.css' |
Real-World Examples
1. Basic button module
.button { padding: 8px 16px; }
<button className={styles.button}>Click</button>
2. Conditional variant
className={classNames(styles.btn, { [styles.primary]: isPrimary })}
3. Composition
.card { composes: base; }
4. Global heading
:global(.page-title) { font-size: 24px; }
5. TypeScript declaration
declare module '*.module.css';
6. Bracket notation
className={styles['nav-link']}
7. Mixing with global
className={`${styles.wrapper} container`}
8. Sass module
import styles from './App.module.scss'
9. Vite custom name format
css: { modules: { generateScopedName: '[name]__[local]___[hash:base64:5]' } }
10. Next.js co-located module
import styles from './styles.module.css'
Visual
The Transformation
┌──────────────────────────────────────────────┐
│ Button.module.css │
│ .button { padding: 8px; } │
│ │ │
│ ▼ │
│ Build tool transforms │
│ │ │
│ ▼ │
│ CSS output: │
│ .Button_button__2Xdp8 { padding: 8px; } │
│ │ │
│ ▼ │
│ JS import: │
│ styles = { button: "Button_button__2Xdp8" } │
│ │ │
│ ▼ │
│ <button className={styles.button}> │
│ Renders: className="Button_button__2Xdp8" │
└──────────────────────────────────────────────┘
Scoping vs Global
┌──────────────────────────────────────────────┐
│ LOCAL (default) │
│ .card → .Component_card__hash │
│ Only available through styles.card │
│ │
│ GLOBAL (:global) │
│ :global(.card) → .card │
│ Available anywhere, not scoped │
└──────────────────────────────────────────────┘
Composition
┌──────────────────────────────────────────────┐
│ .base { border: 1px solid; } │
│ .card { composes: base; padding: 16px; } │
│ │ │
│ ▼ │
│ .card output: │
│ border: 1px solid; │
│ padding: 16px; │
│ (composed styles are copied in) │
└──────────────────────────────────────────────┘
Tooling Support
┌──────────────────────────────────────────────┐
│ Vite → automatic .module.css │
│ CRA → automatic .module.css │
│ Next.js → automatic .module.css │
│ TypeScript → needs .d.ts declaration │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Extension | .module.css (or .module.scss) |
| Import | import styles from './file.module.css' |
| Access | styles.className |
| Hyphenated | styles['class-name'] |
| Conditional | classnames library |
| Composition | composes: base; |
| Global escape | :global(.name) |
| TypeScript | declare module '*.module.css' |
| Build tools | Vite, CRA, Next.js support automatically |
Key takeaways:
- CSS Modules scope class names automatically. The build tool generates unique names, and you access them through the imported object .
- The import returns an object, not a side effect.
import styles from './x.module.css'gives you a mapping;import './x.module.css'does not. - Conditional classes are cleaner with
classnames. Nested ternaries in template literals become unreadable quickly . composesshares styles at build time. It copies declarations into the composed class, and it works across module files .:globalis an escape hatch, not a pattern. Use it for third-party integration or true global styles, not for regular component styling.- TypeScript needs a declaration. Without it,
.module.cssimports produce a module-not-found error . - Vite, CRA, and Next.js all support CSS Modules out of the box. No additional configuration is required for the basic pattern .
Remember: CSS Modules are the lowest-friction way to get scoped styles in React without adopting a runtime CSS-in-JS library. You write normal CSS in a .module.css file, import the resulting object, and apply class names through it. The build tool handles the transformation, the generated names cannot collide, and the styles are co-located with the component that uses them. The trade-off is a small amount of ceremony—the import, the object access, the TypeScript declaration—and the loss of global class names unless you explicitly opt in with :global. For projects that want the separation of CSS and JSX without the global namespace problem, CSS Modules are the default choice.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!