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.csswhose 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.
.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.
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.
// 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",
};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.
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
Rename a class in only one module
In the simulation, change
modalStyles.titleto"_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
Add a composed class
Add
selected: "_selected_x7f2a_14 _card_x7f2a_1"tocardStyles, a CSS rule._selected_x7f2a_14 { outline: 3px solid #4dabf7; }, and use it for the plain card. What does its loggedclassNamelook like now? - 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
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
Console
Nothing logged yet.Expected console output
Product card -> rgb(201, 42, 42) uppercase
Delete item? -> rgb(201, 42, 42) uppercaseEach 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
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/* 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.
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".
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-modulesspec), implemented bypostcss-modulesandcss-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
.cssfile next to its JS. Consumers must import that CSS (or the bundler must handle the library's.module.cssimports). 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
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
A CSS Module is a CSS file whose name ends in
.module.css(for exampleCard.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 writeimport styles from "./Card.module.css"and thenclassName={styles.title}. The class names are now scoped (they belong to one file) even though the CSS ends up in an ordinary stylesheet. - 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_5in 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 throughstyles.something. Because the hash comes from the file, the same source always gives the same name, which keeps builds stable and cacheable. - 4
composeslets one class reuse another:.featured { composes: card; border-color: gold; }. In JavaScript,styles.featuredthen 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
Sometimes you need a name that is not renamed: a class set by a third-party library, a
.darkclass on<html>, or a state class added by other code. Wrap it in:global(...)::global(.dark) .card { background: #222; }keeps.darkas is and still scopes.card. Element selectors (h2,a), ids and attribute selectors are never renamed, so.card h2is fine. Global resets and fonts belong in one plain global stylesheet (Topic 11.1). - 6
In the Hectal preview there is no bundler, so the live example below simulates a module: a hand-written
stylesobject 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
What problem do CSS Modules solve, and how do they solve it?
Walk through what happens between writing .title in Card.module.css and the browser styling an <h2>.
What do composes and :global(...) do?
Why should you never write a generated class name like _title_x7f2a_5 by hand?
Practice
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.
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
styleor 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.cssfile and use it throughstyles.name.I can describe how a bundler generates and maps scoped names.
I can use
composesand:global(...).I know why generated names must never be hard-coded.