| |

React 71 ⚛️ CSS Modules in React

⏱️ Estimated read: 11 min | |

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

ItemExample
CSS Module fileButton.module.css
Preprocessor moduleButton.module.scss
Importimport styles from './Button.module.css'
Apply classclassName={styles.button}
Hyphenated keystyles['button-primary']

Class Application Patterns

PatternCode
Single classclassName={styles.button}
Multiple classesclassName={`${styles.a} ${styles.b}`}
ConditionalclassName={cond ? styles.a : styles.b}
With classnamesclassNames({ [styles.a]: cond })
Mix with globalclassName={`${styles.a} global`}

Composition and Global

FeatureSyntax
Compose classcomposes: base;
Global selector:global(.name) { ... }
Global child in local.local :global(.child) { ... }

Tooling Notes

ToolBehavior
ViteAutomatic for .module.css
Create React AppAutomatic for .module.css
Next.jsAutomatic for .module.css
TypeScriptNeeds 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

PitfallWhy It HappensFix
styles is undefinedImported without from, or no declarationimport styles from ...; add .d.ts
Class not appliedUsed plain string instead of styles.nameReference the imported object
Hyphenated class undefinedDot notation on hyphenated keystyles['my-class']
TypeScript module errorNo declaration filedeclare module '*.module.css'
Styles leak globallyUsed :global unnecessarilyKeep selectors local
Composition not workingcomposes references a class in another file without pathUse 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

ItemValue
Extension.module.css (or .module.scss)
Importimport styles from './file.module.css'
Accessstyles.className
Hyphenatedstyles['class-name']
Conditionalclassnames library
Compositioncomposes: base;
Global escape:global(.name)
TypeScriptdeclare module '*.module.css'
Build toolsVite, 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 .
  • composes shares styles at build time. It copies declarations into the composed class, and it works across module files .
  • :global is 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.css imports 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!