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
/* Card.module.css */
.title {
font-size: 1.25rem;
font-weight: 600;
}
.featured {
color: goldenrod;
}<!-- 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>.title and never collide — each compiles to its own unique class in the final HTML.Using it with React / Next.js
<!-- 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.
/* 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 |
|---|---|
| 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.
/* 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;
}: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.
/* 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
composesreplaces most of what a preprocessor@extendwas used forUse
:globalonly for genuine third-party integration pointsWorks natively with Next.js — any
*.module.cssfile 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.
/* Card.module.css */
.title { }
.body { }<!-- 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.
/* 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 ( |
camelCase vs kebab-case export mode | Some loader configurations expose |
Global styles accidentally scoped | A |