Concept
CSS Modules solve CSS's fundamental global-namespace problem with a deliberately simple mechanism: any CSS file named with a .module.css convention gets its class names automatically rewritten to be locally scoped at build time, .title in one file compiles to something like .Card_title__a8Xf2, guaranteed unique across the entire built application, while a .title class in a completely different .module.css file gets a different unique hash, and the two can never collide, even though both authors wrote the exact same simple class name with zero coordination.
Basic usage
/* Card.module.css */
.card {
padding: 1rem;
border-radius: 8px;
}
.title {
font-size: 1.25rem;
}// Card.jsx
import styles from "./Card.module.css";
function Card() {
return (
<div className={styles.card}>
<h2 className={styles.title}>Title</h2>
</div>
);
}styles is a plain JS object mapping the original class names you wrote (card, title) to their build-time-generated unique hashed versions, you write and read normal-looking class names in your source, and the build tool handles the actual uniqueness guarantee transparently.
Composition, reuse without global classes
.baseButton {
padding: 8px 16px;
border-radius: 6px;
font-weight: 600;
}
.primaryButton {
composes: baseButton;
background: #4f46e5;
color: white;
}<button className={styles.primaryButton}>Submit</button>
// Resulting className includes BOTH the baseButton and primaryButton hashed classescomposes lets one local class inherit another local class's styles (and, notably, composes: baseButton from "./shared.module.css" can compose from an entirely different module file), a scoped alternative to Sass's @extend or manually applying two className strings, keeping the actual set of applied styles legible from the CSS file itself rather than scattered across multiple className="a b" call sites.
:global, the deliberate escape hatch
.card {
padding: 1rem;
}
:global(.dark-theme) .card {
background: #1f2937;
}
:global {
.some-truly-global-class { color: red; }
}Sometimes a rule genuinely needs to target something outside the module's local scope, a global theme class toggled on <body>, or third-party library markup you don't control the class names of. :global(...) (or a :global { } block) explicitly opts specific selectors out of the automatic local scoping, making the "this one is intentionally global" decision visible and deliberate in the source rather than accidental.
How it actually works (build-time, not runtime)
CSS Modules is a build-time convention interpreted by your bundler (webpack's css-loader, Vite's built-in support, Next.js's built-in support), there's no CSS Modules runtime, no JS library shipped to the browser, no performance cost beyond the class name string being slightly longer than an unhashed one would be. This is a meaningful contrast with CSS-in-JS solutions (covered in the next topic), some of which do have real runtime cost for style injection/serialization.
TypeScript integration
// Without type generation, styles.title has type `any`, no autocomplete, no typo detection
import styles from "./Card.module.css"; // styles: any (by default)// With a typed-css-modules setup (or Vite/Next's built-in typing), get real autocomplete + compile errors on typos
import styles from "./Card.module.css"; // styles: { card: string; title: string }A common real gap in CSS Modules setups: without additional tooling (typed-css-modules, or framework-level support), referencing styles.titel (a typo) compiles fine and silently fails at runtime (undefined className, no visible styling), since the default TS types for CSS Module imports are just any. Modern framework tooling (Next.js, Vite with the right plugin) increasingly generates real types automatically.
Common Mistakes
1. Forgetting the .module.css naming convention
Card.css → plain global CSS, NOT scoped, even in a project using CSS Modules elsewhere
Card.module.css → scoped CSS ModuleMost build tooling distinguishes CSS Modules from plain CSS purely by the .module.css filename convention, a regular .css import in the same project remains fully global, which can be a source of confusion (or an intentional choice for genuinely global styles like resets) if not applied deliberately.
2. Assuming composes works like Sass's nesting/nested nesting nesting
/* composes must be the FIRST declaration in the rule, and only composes local/imported classes */
.button {
composes: base; /* must come first */
padding: 8px;
}composes has specific syntactic requirements (must appear before other declarations in the rule) and composes classes, not arbitrary selectors, it's not a general-purpose selector inheritance mechanism the way Sass's @extend or CSS's forthcoming native nesting might suggest by analogy.
3. Reaching for :global more than genuinely necessary
Every :global usage reintroduces the exact collision risk CSS Modules exists to prevent, for that specific selector, reserve it for genuinely necessary cases (targeting third-party markup, a real global theme toggle class), not as a convenient way to avoid understanding the local-scoping model.
4. Not typing CSS Module imports, causing silent typo bugs
Referencing a class name that doesn't exist in the CSS file (a typo, or a class that was renamed/removed) compiles without error and produces undefined at runtime, the resulting element simply has no className applied for that reference, silently, with no styling and no console warning. Set up typed CSS Modules (or use framework tooling that provides it built-in) to catch this at compile time instead.
5. Expecting CSS Modules to solve specificity/cascade problems, not just naming collisions
CSS Modules solves naming collisions (guaranteed-unique class names), it does not change how CSS specificity or cascade order works within your own styles. Deeply nested selectors within a single module file can still create the same specificity escalation problems covered in the CSS Architecture topic; CSS Modules and BEM/ITCSS-style discipline are complementary, not redundant.
Best Practices
- Use the
.module.cssnaming convention consistently and intentionally, decide deliberately which stylesheets are scoped versus genuinely global (resets, third-party overrides). - Set up typed CSS Module imports (via framework built-in support or
typed-css-modules) to catch typo/missing-class bugs at compile time rather than silently at runtime. - Use
composesfor genuine local-scope reuse; reserve:globalfor real, deliberate exceptions, not convenience. - Still apply specificity/naming discipline within module files, scoping solves collisions, not cascade complexity.
- Combine with BEM-style clarity inside a single module if a component has many related classes, even though global collision is no longer a concern, readability within the file still benefits from clear naming.
Further Resources
- CSS Modules, GitHub / official docs
- Next.js, CSS Modules support
- Vite, CSS Modules support
- CSS Tricks, CSS Modules
- typed-css-modules
