Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~24 min· topic 2 of 6

Topic 11.2

CSS Modules

In one line

A CSS Module is a .module.css file whose class names the bundler renames to unique ones, so .title in one component can never clash with .title in another; you import the file as an object that maps your names to the generated ones.

Think of it like this

Two classes in one school both have a pupil called Arjun. If the head teacher shouts "Arjun!" in the corridor, both boys turn round. So the register writes them as Arjun (7B) and Arjun (8C). Each class still calls its own pupil "Arjun", but the school-wide name is unique. CSS Modules do this for class names: you write .title, the tool writes ._title_x7f2a_5 in one file and ._title_p91kd_1 in another.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

CSS Module
A CSS file named *.module.css whose class names are renamed to unique ones and imported as an object.
Scoped
Belonging to one place only. A scoped class name can't affect elements in other components.
Bundler
A build tool (Vite, webpack, Turbopack) that combines your source files into files the browser loads.
Hash
A short code computed from some text. The same text always gives the same code.
styles object
What import styles from "./X.module.css" gives you: { title: "_title_x7f2a_5", ... }.
composes
A CSS Modules keyword that makes one class also include another class's rules.
:global(...)
Marks a selector inside a module as global so the bundler leaves its name alone.
Name collision
Two unrelated rules using the same class name and styling each other's elements by accident.

Step by step

01Write a module file next to the component

Name the file after the component and end it with .module.css. Use short, plain class names: they only need to be unique inside this file.

src/components/Card.module.csswhole filecss
.card {
  border: 2px solid #ced4da;
  border-radius: 10px;
  padding: 10px 14px;
}

.title {
  color: #1864ab;
  margin: 0;
}

.featured {
  composes: card;
  border-color: #f59f00;
}

/* .dark is set on <html> by other code: keep it global */
:global(.dark) .card {
  background: #212529;
}

02Import the module and use its names

The default import is the mapping object. styles.title is a string, so it works anywhere a class name works, including inside clsx. Class names with dashes need bracket access: styles["is-active"], which is why many teams write module classes in camelCase.

src/components/Card.jsxwhole filejsx
import styles from "./Card.module.css";

export function Card({ featured, title, children }) {
  return (
    <article className={featured ? styles.featured : styles.card}>
      <h2 className={styles.title}>{title}</h2>
      {children}
    </article>
  );
}

03The bundler rewrites the CSS and the import

At build time the bundler renames the classes in the CSS and turns your import into a plain object. Both halves use the same generated names, so they always match.

What the bundler produces (Vite, development)whole filejavascript
// CSS that reaches the browser:
// ._card_x7f2a_1 { border: 2px solid #ced4da; ... }
// ._title_x7f2a_5 { color: #1864ab; margin: 0; }
// ._featured_x7f2a_9 { border-color: #f59f00; }
// .dark ._card_x7f2a_1 { background: #212529; }

// What `styles` holds in Card.jsx:
const styles = {
  card: "_card_x7f2a_1",
  title: "_title_x7f2a_5",
  featured: "_featured_x7f2a_9 _card_x7f2a_1",
};
The bundler rewrites the CSS and the importdiagram
Rendering diagram…

04Tune the naming in Vite (optional)

Vite passes css.modules options to its CSS Modules plugin. localsConvention: "camelCaseOnly" lets you write .is-active in CSS and read styles.isActive in JS. generateScopedName changes the pattern; [name] is the file name, [local] your class name.

vite.config.jswhole filejavascript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  css: {
    modules: {
      localsConvention: "camelCaseOnly",
      generateScopedName: "[name]__[local]___[hash:base64:5]",
    },
  },
});

Try it yourself

  1. 1

    Rename a class in only one module

    In the simulation, change modalStyles.title to "_heading_p91kd_1" and the matching rule in the CSS tab. Predict: does anything about the card change? Renaming inside one module can never affect another.

  2. 2

    Add a composed class

    Add selected: "_selected_x7f2a_14 _card_x7f2a_1" to cardStyles, a CSS rule ._selected_x7f2a_14 { outline: 3px solid #4dabf7; }, and use it for the plain card. What does its logged className look like now?

  3. 3

    Fix the collision the old way

    In the first example, rename the classes BEM-style (card__title, modal__title). It works, but notice that the fix relies on every developer following the rule; CSS Modules enforce it with a tool.

Code & diagrams

The problem: two teams, one global class name New tab

Both headings are red and uppercase. Team B's rule came later in the cascade, so it wins for every `.title` on the page, including the card's. Nothing in the console warns about it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Product card -> rgb(201, 42, 42) uppercase
Delete item? -> rgb(201, 42, 42) uppercase
CSS Modules, simulated: same local name, different real names New tab

Each file's `.title` became a different real class, so the card titles stay blue and the modal title stays red. The featured card has two classes because of `composes`. Click the button: `.dark` is global, so it is not renamed, and the scoped card rules react to it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

_title_x7f2a_5 vs _title_p91kd_1
Plain card -> rgb(24, 100, 171)
Featured card -> rgb(24, 100, 171)
Delete item? -> rgb(201, 42, 42)
_featured_x7f2a_9 _card_x7f2a_1
Card.module.css and Card.jsx in a real Vite projectjsx
/* src/components/Card.module.css */
.card { border: 2px solid #ced4da; border-radius: 10px; padding: 10px 14px; }
.title { color: #1864ab; margin: 0; }
.featured { composes: card; border-color: #f59f00; }
.selected { outline: 3px solid #4dabf7; }
:global(.dark) .card { background: #212529; }

// src/components/Card.jsx
import clsx from "clsx";
import styles from "./Card.module.css";

export function Card({ featured, selected, title }) {
  return (
    <article className={clsx(featured ? styles.featured : styles.card, selected && styles.selected)}>
      <h2 className={styles.title}>{title}</h2>
    </article>
  );
}

// In the browser (DevTools, Elements):
// <article class="_featured_x7f2a_9 _card_x7f2a_1"><h2 class="_title_x7f2a_5">…</h2></article>

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Misspell a module class

In the simulation, change cardStyles.title to cardStyles.titel in Card.

terminal
$ Preview and console
── what you'll see ──
Card titles lose their blue colour. No error, no warning. The first console line still prints, and the card titles now log rgb(0, 0, 0).

Break #2

Name the file Card.css instead of Card.module.css

In a Vite project, rename the file to Card.css and keep import styles from "./Card.css".

terminal
$ Browser
── what you'll see ──
The rules apply globally under their original names (.title), and styles.title is undefined, so the component's own elements get no class at all.

Myth vs fact

Myth

CSS Modules are a runtime library that ships to the browser.

Fact

They are a build-time transform. The browser receives ordinary CSS and a small object of strings; there is no styling code running in the browser.

Myth

Scoped classes mean the cascade no longer matters.

Fact

Specificity, source order and inheritance still apply. Scoping only guarantees that two files can't pick the same class name by accident.

Myth

You can't style a child component's inside from a parent module.

Fact

You can pass a class down as a prop (<Button className={styles.wide} />) and merge it inside the child. Reaching into a child's internals with element selectors works too but couples the files.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    CSS Modules is a convention from 2015 (the css-modules spec), implemented by postcss-modules and css-loader; Vite, Next.js (webpack and Turbopack) and Parcel support it out of the box. Lightning CSS, which Vite can use instead of PostCSS, implements it too.

  • ▸

    Hash stability matters for long-term caching: names derive from the file path and content settings, so moving a file changes its generated names. Server-rendered HTML and client JS must agree on names, which they do because both come from the same build.

  • ▸

    A component library built with CSS Modules ships a compiled .css file next to its JS. Consumers must import that CSS (or the bundler must handle the library's .module.css imports). This is one reason library authors moved to Tailwind-based copy-in components or zero-runtime CSS-in-JS (Topics 11.3 to 11.5).

  • ▸

    With Server Components (Phase 20), CSS Modules need no client JavaScript at all: the server renders class="_card_x7f2a_1" and the framework links the CSS file. That makes them one of the safest choices for App Router projects.

Remember this

  1. 1

    The problem first. Every rule in a normal stylesheet is global: it applies to any element on the page with that class. When team A writes .title { color: blue } for a card and team B writes .title { color: red } for a modal, whichever rule the browser reads last wins everywhere, and both teams' headings turn red. Nothing warns you. In a large app this is the most common styling bug, and fear of it makes people afraid to delete old CSS.

  2. 2

    A CSS Module is a CSS file whose name ends in .module.css (for example Card.module.css). Vite, Next.js and most bundlers (the tools that combine your files for the browser) treat that ending specially: they rename every class in the file to a unique name and give your JavaScript an object that maps the names you wrote to the generated ones. You write import styles from "./Card.module.css" and then className={styles.title}. The class names are now scoped (they belong to one file) even though the CSS ends up in an ordinary stylesheet.

  3. 3

    How the names are made: the bundler combines the file path and the class name, runs them through a hash (a function that turns any text into a short, fixed-looking code) and builds a name such as _title_x7f2a_5 in Vite or a different pattern in Next.js. The exact pattern depends on the bundler and its settings, so never type a generated name by hand: always go through styles.something. Because the hash comes from the file, the same source always gives the same name, which keeps builds stable and cacheable.

  4. 4

    composes lets one class reuse another: .featured { composes: card; border-color: gold; }. In JavaScript, styles.featured then holds two generated names, "_featured_x7f2a_9 _card_x7f2a_1", so the element gets both rule sets. You can compose from another module (composes: button from "./base.module.css";). Composition only works with single class selectors, and it is a CSS Modules feature, not standard CSS.

  5. 5

    Sometimes you need a name that is not renamed: a class set by a third-party library, a .dark class on <html>, or a state class added by other code. Wrap it in :global(...): :global(.dark) .card { background: #222; } keeps .dark as is and still scopes .card. Element selectors (h2, a), ids and attribute selectors are never renamed, so .card h2 is fine. Global resets and fonts belong in one plain global stylesheet (Topic 11.1).

  6. 6

    In the Hectal preview there is no bundler, so the live example below simulates a module: a hand-written styles object maps names to hashed-looking class names, and the CSS tab contains the rules a bundler would have generated. The JSX is exactly what you write in a real project.

Explain it without notes

01

What problem do CSS Modules solve, and how do they solve it?

02

Walk through what happens between writing .title in Card.module.css and the browser styling an <h2>.

03

What do composes and :global(...) do?

04

Why should you never write a generated class name like _title_x7f2a_5 by hand?

Practice

01

Write Button.module.css with classes button, primary (which composes button) and ghost (which composes button), and a Button component that takes variant and picks the right class.

02

A third-party date picker adds the class .rdp-day_selected to selected days. Write a CSS Modules rule in Calendar.module.css that colours selected days teal only inside your .calendar wrapper.

Trade-offs

  • ↔

    CSS Modules give scoping with zero runtime cost and plain CSS syntax, but styles live in a separate file and dynamic values still need style or CSS variables.

  • ↔

    Generated names are hard to target from outside (tests, user stylesheets, other teams). Use data-* attributes or roles for tests rather than class names.

Done when you can

  • I can explain the global name collision problem.

  • I can write a .module.css file and use it through styles.name.

  • I can describe how a bundler generates and maps scoped names.

  • I can use composes and :global(...).

  • I know why generated names must never be hard-coded.