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.scss if 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 accessstyles.someClass reads the scoped class name for .someClass; use bracket syntax styles["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.css file, 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.
  • Importimport 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 to className. React treats it like any other string prop and sets the DOM element’s class attribute 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 .css file 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.css file next to the component that uses it (e.g. Card.jsx and Card.module.css in the same folder) so the relationship is obvious and the pair is easy to move or delete together.
  • Reach for a small utility like clsx or classnames once you have more than one conditional class — it avoids stray spaces and stringified false/undefined values.
  • Use composes to share common styling (spacing, typography, resets) across related classes instead of duplicating CSS declarations.
  • Keep genuinely global styles (CSS resets, design tokens, :root custom properties, base html/body rules) 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.css file with a base .badge class and two variant classes, .success and .error, each setting a different background color. Build a Badge component that accepts a variant prop ("success" or "error") and combines styles.badge with the matching variant class.
  • Take the Card component from Example 2 and refactor its conditional className logic to use the clsx package 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 .title with 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 .title classes 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 onto className.
  • 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 clsx to avoid leaking literal "false"/"undefined" strings into the DOM.
  • composes lets classes share styling rules across the same file or different files without duplicating CSS.