CSSCSS Modules

CSS Modules

CSS Modules solve the global-scope problem of plain CSS with a build-time transform: every class name in a .module.css file gets rewritten to a unique, hashed identifier, so it can never accidentally collide with a class of the same name defined somewhere else in the project.

How scoping works under the hood

CSS
/* Card.module.css */
.title {
  font-size: 1.25rem;
  font-weight: 600;
}

.featured {
  color: goldenrod;
}

HTML
<!-- Conceptually, imported and used like this: -->
<!-- import styles from './Card.module.css' -->
<!-- <h3 className={styles.title}>Card title</h3> -->

<!-- The build step compiles .title into a globally-unique class,
     roughly: -->
<h3 class="Card_title__a1B2c">Card title</h3>
Where the hash comes from
The loader generates the hashed suffix from the file path and the original class name (and sometimes a content hash), so two different files can both define .title and never collide — each compiles to its own unique class in the final HTML.
Using it with React / Next.js

HTML
<!-- Next.js supports *.module.css out of the box, no config needed -->
<!-- Card.module.css -->
.card {
  border-radius: 12px;
  padding: 16px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

<!-- Card.tsx -->
<!-- import styles from './Card.module.css'
     export function Card({ children }) {
       return <div className={styles.card}>{children}</div>
     } -->
composes — sharing styles between modules

composes lets one class inherit the rules of another, without duplicating CSS or reaching for a CSS preprocessor's @extend.

CSS
/* base.module.css */
.button {
  padding: 8px 16px;
  border-radius: 6px;
  border: none;
  font-weight: 600;
}

/* PrimaryButton.module.css */
.primary {
  composes: button from './base.module.css';
  background: #2563eb;
  color: white;
}

Approach

Result

composes: button from './base.module.css'

Adds the base class name alongside the local one on the rendered element

Copy-pasting the base rules into every variant

Works, but drifts out of sync the moment the base style changes

Escaping scoping with :global

Sometimes you need to target a class that is genuinely global — a third-party widget's markup, or a class toggled by JavaScript outside your control. :global opts a selector out of the hashing.

CSS
/* Widget.module.css */
.wrapper {
  padding: 16px;
}

/* Targets a real global class, unmodified */
:global(.third-party-widget) {
  max-width: 100%;
}

/* Mixing local and global in one selector */
.wrapper :global(.leaflet-container) {
  border-radius: 8px;
}
Use :global sparingly
Every :global selector opts back into the exact collision risk CSS Modules exists to prevent. Reserve it for genuine integration points with code you do not control, not as a general escape hatch for convenience.
Naming conventions inside modules

Because the file itself already provides scoping, class names inside a CSS Module can be much simpler than BEM's fully-qualified names — there is no risk of .title colliding with another component's .title.

CSS
/* No BEM needed -- the module boundary already prevents collisions */
.title { }
.body { }
.featured { }

/* Still useful: camelCase class names map cleanly to JS property access */
.cardTitle { }
CSS Modules vs plain CSS vs CSS-in-JS

Approach

Scoping mechanism

Tooling required

Plain CSS

None — relies on naming discipline (e.g. BEM)

None

CSS Modules

Build-time class-name hashing

A bundler loader (Webpack, Vite, Next.js built-in)

CSS-in-JS

Runtime or build-time generated unique class names

A library (styled-components, Emotion, vanilla-extract)

  • CSS Modules give real, enforced scoping without a runtime cost

  • Class names in source stay short and simple since the file itself is the namespace

  • composes replaces most of what a preprocessor @extend was used for

  • Use :global only for genuine third-party integration points

  • Works natively with Next.js — any *.module.css file is automatically scoped, no extra config

TypeScript support

Importing a .module.css file into a TypeScript file has no type information by default — the import resolves to any, so a typo in a class name (styles.titel instead of styles.title) is not caught until runtime. A typed-css-modules style build step generates a matching .d.ts file to close that gap.

CSS
/* Card.module.css */
.title { }
.body { }

HTML
<!-- Auto-generated Card.module.css.d.ts (conceptually) -->
<!-- declare const styles: {
       readonly title: string
       readonly body: string
     }
     export default styles -->

<!-- Now styles.titel is a real TypeScript compile error, not a
     silent undefined className at runtime -->
Using CSS Modules with Sass

The two are independent build steps and combine cleanly — Sass compiles nesting, variables, and mixins down to plain CSS first, and the CSS Modules loader then hashes the resulting class names exactly as it would for a plain .module.css file.

CSS
/* Card.module.scss */
$radius: 12px;

.card {
  border-radius: $radius;

  &:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
  }
}
Common gotchas

Gotcha

Explanation

Dynamic class names via string concatenation

Building a class name string at runtime (styles["card-" + variant]) bypasses the compiler's static analysis and any generated types — prefer a lookup object or template literal keyed on known values

camelCase vs kebab-case export mode

Some loader configurations expose card-title only as styles["card-title"], others also generate a camelCase styles.cardTitle alias — check your loader's config before assuming either works

Global styles accidentally scoped

A :global block left with an unbalanced or misplaced selector can leave unrelated rules scoped when they were meant to be global, or vice versa

Next
Compare this with the CSS-in-JS approach in CSS-in-JS Overview, or see the broader landscape in Component-Scoped CSS.