CSS Modules
CSS Modules let you write ordinary CSS files whose class names are automatically scoped to the component that imports them. Instead of one giant global stylesheet where .button in one file can silently clash with .button in another, each CSS Modules file gets its class names rewritten to unique, hashed identifiers at build time. You still write plain CSS — no new language to learn — but you get the safety of local scoping that plain <link>-based stylesheets can’t give you.
Overview / How it works
By default, every class name in a normal CSS file lives in one global namespace shared by the entire page. If two components both define .card, whichever stylesheet loads last wins, and debugging the resulting visual bugs is miserable in a large app. CSS Modules solve this at build time, not at runtime: your bundler (Vite, Create React App, Next.js, and most other modern toolchains support this out of the box) recognizes any file named *.module.css as a CSS Module.
When you import such a file, the bundler does not hand your component the raw CSS text. Instead it runs the file through a CSS Modules loader that rewrites every class selector into a unique string, typically something like Button_button__a1B2c, and generates a plain JavaScript object that maps your original class names to those hashed strings. That object is what gets imported into your component. So import styles from "./Button.module.css" gives you styles.button, which evaluates to the hashed string, not the literal word "button". Because every file’s hashes are generated independently, two different components can both use the class name .card in their own module files and never collide — each compiles to a different hash.
Crucially, this is a build-time transformation, not a React runtime feature. React itself doesn’t know or care that a class name came from a CSS Module — by the time your component renders, styles.button is just a string, and React puts it on the className prop exactly like any other string. There is no extra JavaScript shipped to the browser for scoping, no runtime style injection, and no performance cost beyond what plain CSS already costs. This is what distinguishes CSS Modules from CSS-in-JS libraries, which typically do inject styles at runtime.
Syntax
/* Component.module.css */
.someClass {
/* regular CSS properties */
}
// Component.jsx
import styles from "./Component.module.css";
function Component() {
return <div className={styles.someClass}>Hello</div>;
}
- File name — must end in
.module.css(or.module.scssif you use Sass) for the bundler to treat it as a CSS Module instead of a global stylesheet. - Default import — importing the file gives you an object (conventionally named
styles) whose keys are your class names and whose values are the generated locally-scoped strings. - Property access —
styles.someClassreads the scoped class name for.someClass; use bracket syntaxstyles["some-class"]for kebab-case names, since dot notation isn’t valid JavaScript for identifiers containing a hyphen. composes— a CSS Modules-only at-rule that lets one class inherit the rules of another class, optionally from a different file, without duplicating CSS.:global(...)— an escape hatch to opt a specific selector out of local scoping when you deliberately need a global class name (for example, to target a third-party widget).
Examples
Example 1: A scoped Button component
/* Button.module.css */
.button {
padding: 10px 20px;
border: none;
border-radius: 6px;
background-color: #2563eb;
color: white;
font-weight: 600;
cursor: pointer;
}
.button:hover {
background-color: #1d4ed8;
}
// Button.jsx
import styles from "./Button.module.css";
function Button({ children, onClick }) {
return (
<button className={styles.button} onClick={onClick}>
{children}
</button>
);
}
export default Button;
This renders a rounded, blue, white-text button. Notice that the component never writes the literal string "button" as a className — it always goes through styles.button. In the rendered DOM the actual class attribute will be something like Button_button__a1B2c, guaranteeing no other component’s .button class can affect this element, and vice versa.
Example 2: Conditional classes on a Card
/* Card.module.css */
.card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
}
.active {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.3);
}
// Card.jsx
import { useState } from "react";
import styles from "./Card.module.css";
function Card({ title }) {
const [active, setActive] = useState(false);
const cardClassName = active
? `${styles.card} ${styles.active}`
: styles.card;
return (
<div className={cardClassName} onClick={() => setActive(!active)}>
<h3>{title}</h3>
<p>Click to {active ? "deactivate" : "activate"}</p>
</div>
);
}
export default Card;
Clicking the card toggles the active state. Because className just accepts a plain string, you can combine multiple scoped classes with a template literal — here styles.card is always present, and styles.active is appended only when active is true. This is the same pattern you’d use with global CSS class names; CSS Modules doesn’t change how you combine classes, only where the class names come from.
Example 3: Sharing styles with composes
/* shared.module.css */
.baseButton {
padding: 10px 20px;
border-radius: 6px;
border: none;
font-weight: 600;
cursor: pointer;
}
/* DangerButton.module.css */
.dangerButton {
composes: baseButton from "./shared.module.css";
background-color: #dc2626;
color: white;
}
// DangerButton.jsx
import styles from "./DangerButton.module.css";
function DangerButton({ children, onClick }) {
return (
<button className={styles.dangerButton} onClick={onClick}>
{children}
</button>
);
}
export default DangerButton;
Here styles.dangerButton resolves to two scoped class names joined by a space — one for .dangerButton‘s own rules (red background) and one for the composed .baseButton rules (padding, radius, cursor) pulled in from shared.module.css. composes lets you reuse styling logic across files without copy-pasting CSS or reaching for a preprocessor mixin.
How it works step by step
- Build time — the bundler’s CSS Modules loader parses each
*.module.cssfile, rewrites every local class selector to a unique hashed name (scoped to that file, and often to the build), and produces a JS object mapping original names to hashes. - Import —
import styles from "./X.module.css"pulls in that generated object. This happens once, when the module is first loaded, not on every render. - Render — your component reads properties off
styles(e.g.styles.card) and passes the resulting string toclassName. React treats it like any other string prop and sets the DOM element’sclassattribute accordingly. - Style injection — the actual CSS rules (with the hashed selectors) are extracted into a real stylesheet, either inlined via a
<style>tag in development or bundled into a static.cssfile for production. The browser applies them exactly like any normal stylesheet — there’s no JavaScript-driven styling at runtime.
Common Mistakes
Mistake 1: Using kebab-case class names with dot notation
/* wrong: styles.primary-button is invalid JavaScript */
import styles from "./Button.module.css";
function Button() {
return <button className={styles.primary-button}>Save</button>;
}
If your CSS file defines .primary-button, styles.primary-button is not valid JavaScript — the hyphen is parsed as subtraction (styles.primary - button), which throws a reference error for the undefined variable button. Either access it with bracket notation, styles["primary-button"], or — the better long-term fix — name your CSS classes in camelCase (.primaryButton) so plain dot notation always works.
/* Button.module.css */
.primaryButton {
background-color: #2563eb;
color: white;
}
import styles from "./Button.module.css";
function Button() {
return <button className={styles.primaryButton}>Save</button>;
}
Mistake 2: Template literals that leak the literal word “false”
/* wrong: results in className="card false" when isActive is false */
const className = `${styles.card} ${isActive && styles.active}`;
When isActive is false, isActive && styles.active evaluates to false, and template literals coerce that to the literal string "false" — so the element ends up with a bogus false class in its class attribute. It won’t match anything in your CSS, but it’s messy and can bite you in tests or DOM snapshots. Use a ternary that falls back to an empty string, or a small utility like clsx/classnames:
const className = isActive ? `${styles.card} ${styles.active}` : styles.card;
// or, with the clsx package:
import clsx from "clsx";
const className = clsx(styles.card, isActive && styles.active);
Best Practices
- Name CSS class selectors in
camelCase(e.g..cardHeader) so you can always use dot notation (styles.cardHeader) without bracket-syntax workarounds. - Colocate each
*.module.cssfile next to the component that uses it (e.g.Card.jsxandCard.module.cssin the same folder) so the relationship is obvious and the pair is easy to move or delete together. - Reach for a small utility like
clsxorclassnamesonce you have more than one conditional class — it avoids stray spaces and stringifiedfalse/undefinedvalues. - Use
composesto share common styling (spacing, typography, resets) across related classes instead of duplicating CSS declarations. - Keep genuinely global styles (CSS resets, design tokens,
:rootcustom properties, basehtml/bodyrules) in a regular, non-module stylesheet imported once at the app root — CSS Modules are for component-local styling, not global concerns. - Don’t fight the local scoping with
:global(...)unless you have a real reason (styling markup you don’t control); overusing it defeats the purpose of CSS Modules.
Practice Exercises
- Create a
Badge.module.cssfile with a base.badgeclass and two variant classes,.successand.error, each setting a different background color. Build aBadgecomponent that accepts avariantprop ("success"or"error") and combinesstyles.badgewith the matching variant class. - Take the
Cardcomponent from Example 2 and refactor its conditional className logic to use theclsxpackage instead of a ternary. Confirm the rendered class list is identical in both the active and inactive states. - Create two separate module files that each define a class named
.titlewith different font sizes, import both into the same component, and apply one to an<h2>and the other to a<p>. Inspect the rendered DOM (mentally, or in a real project) and confirm the two.titleclasses compiled to two different hashed names.
Summary
- CSS Modules are regular CSS files, named
*.module.css, whose class names are rewritten to unique hashed strings at build time so they never collide with class names in other files. - Importing a module CSS file gives you a plain JS object (conventionally
styles) mapping your original class names to their scoped versions; you pass those ontoclassName. - This scoping happens entirely at build time with zero runtime cost — React just receives ordinary strings for
className. - Use bracket notation or camelCase class names to avoid invalid dot-notation access on hyphenated names.
- Combine multiple classes with a ternary or a utility like
clsxto avoid leaking literal"false"/"undefined"strings into the DOM. composeslets classes share styling rules across the same file or different files without duplicating CSS.
