Step 13 of 21 · branch option
CSS Modules
Style React components with locally scoped CSS Modules in Vite, compose and combine classes, and theme them with custom properties.
Plain CSS has one global namespace. In a component codebase that becomes a problem fast: two developers both write .title, a "small" edit to .card restyles a page nobody tested, and nobody dares delete a class because they cannot tell where it is used. CSS Modules fix this without changing the language. You keep writing ordinary CSS in a file per component, and the build tool makes every class name unique to that file. This lesson covers CSS Modules in Vite and React: scoping, importing, composing and combining classes, global styles, theming with custom properties, and typing. (The alternative branch, Tailwind CSS, styles with utility classes in the markup instead; both reach the same result.)
Scoped class names
A CSS Module is any file whose name ends in .module.css. Vite supports them out of the box: nothing to install or configure. Start from a Vite React + TypeScript project:
npm create vite@latest shop -- --template react-ts
cd shop
npm installWrite a module next to its component:
/* src/Badge.module.css */
.badge {
display: inline-flex;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
background: #dcfce7;
color: #166534;
}Importing the file gives you an object that maps the names you wrote to the names that end up in the page:
// src/Badge.tsx
import styles from './Badge.module.css'
export function Badge({ label }: { label: string }) {
return <span className={styles.badge}>{label}</span>
}At build time Vite rewrites every class selector in the file to a unique name and injects the CSS. console.log(styles) shows the mapping:
{ badge: "_badge_1k3z9_1" }The rendered HTML is <span class="_badge_1k3z9_1">New</span>, and the stylesheet contains ._badge_1k3z9_1 { ... }. The default name is the original class, a hash of the file's contents and the line number. Another file's .badge gets a different hash, so the two can never collide. You choose short, obvious names (.title, .image, .button) without prefixes or naming conventions like BEM, because the file is the namespace.
What is and is not scoped
Only some things are renamed. Knowing the list prevents most CSS Modules bugs.
In a .module.css file | Scoped to the file? |
|---|---|
Class selectors .title | Yes |
ID selectors #cart | Yes |
@keyframes names | Yes, and animation references in the same file are rewritten to match |
Element selectors img, h2 | No: a bare img { } styles every image in the app |
Custom properties --accent | No: they are ordinary global CSS variables |
Keep each selector anchored to a local class: .card img is fine (it only matches inside your card), but img on its own leaks.
Naming: camelCase or brackets
The keys of styles are the class names exactly as written. A kebab-case class needs bracket access, styles['add-button'], which is awkward. Either write class names in camelCase (.addButton, used as styles.addButton), or tell Vite to convert them:
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
css: {
modules: { localsConvention: 'camelCaseOnly' }, // .add-button → styles.addButton
},
})Combining and composing classes
Several classes on one element
className is a single string, so combining module classes is string joining. Template literals work, but conditions get messy. The small clsx package (npm install clsx) takes strings, falsy values (which it drops) and objects:
import clsx from 'clsx'
import styles from './Tab.module.css'
type TabProps = { label: string; active: boolean; disabled?: boolean }
export function Tab({ label, active, disabled = false }: TabProps) {
return (
<button
type="button"
disabled={disabled}
className={clsx(styles.tab, active && styles.active, { [styles.muted]: disabled })}
>
{label}
</button>
)
}When the active tab renders, its class is _tab_8fj2k_1 _active_8fj2k_12.
Variants with data attributes
For a fixed set of variants, a data attribute is often cleaner than one class per variant. React passes data-* attributes straight through, and the CSS reads like a specification:
/* src/Button.module.css */
.button { padding: 0.5rem 1rem; border-radius: 0.5rem; background: var(--color-brand); color: white; }
.button[data-variant='secondary'] { background: transparent; color: var(--color-text); border: 1px solid var(--color-border); }<button className={styles.button} data-variant={variant}>{children}</button>Accepting a className from the parent
Reusable components should let the caller add layout classes such as margins:
import clsx from 'clsx'
import type { ComponentProps } from 'react'
import styles from './Button.module.css'
type ButtonProps = ComponentProps<'button'> & { variant?: 'primary' | 'secondary' }
export function Button({ variant = 'primary', type = 'button', className, ...props }: ButtonProps) {
return <button type={type} data-variant={variant} className={clsx(styles.button, className)} {...props} />
}composes
Within CSS, one class can include others with composes. It must be the first declaration in a rule with a single class selector:
/* src/Alert.module.css */
.base { padding: 1rem; border-radius: 0.5rem; border: 1px solid; }
.error {
composes: base;
composes: visuallyStrong from './typography.module.css';
background: #fef2f2;
color: #991b1b;
}styles.error now contains three generated names, _error_… _base_… _visuallyStrong_…, so the element receives all their rules. Unlike Sass @extend, nothing is copied: the class list gets longer instead.
Global styles and :global
Not everything should be scoped. Resets, base typography and design tokens are meant to apply everywhere. Put them in a normal stylesheet, without .module, and import it once from src/main.tsx:
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { App } from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)Inside a module, :global(...) opts a selector out of renaming. Use it when you need to match a class you do not control, such as one added by a third-party widget or a theme class on <html>, and anchor it to a local class so it stays contained:
/* src/DatePicker.module.css */
.wrapper :global(.rdp-day_selected) {
background: var(--color-brand);
}This compiles to ._wrapper_x2k9d_1 .rdp-day_selected: only selected days inside your wrapper are affected. A bare :global(.button) { } would be as global as any other stylesheet, which defeats the point.
Theming and dark mode with custom properties
Because custom properties are global and inherited, they are the natural way to share design tokens between modules. Define them once in index.css, then redefine them for dark mode:
/* src/index.css */
:root {
color-scheme: light dark;
--color-bg: #f8fafc;
--color-surface: #ffffff;
--color-border: #e5e7eb;
--color-text: #111827;
--color-brand: #2563eb;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #030712;
--color-surface: #111827;
--color-border: #1f2937;
--color-text: #f9fafb;
}
}Modules never mention dark mode at all. They use var(--color-surface), and the value switches when the operating system does. color-scheme: light dark also tells the browser to draw form controls and scrollbars in the matching scheme.
If your app has its own theme toggle, set an attribute on <html> from React (document.documentElement.dataset.theme = 'dark') and add a :root[data-theme='dark'] { ... } block alongside the media query. Again, no module changes.
You can also feed a value from React into a module through a custom property, which is how you handle truly dynamic values like a progress percentage or a user-chosen colour. React's CSSProperties type does not know your property name, so assert the type:
import type { CSSProperties } from 'react'
import styles from './Progress.module.css'
export function Progress({ value }: { value: number }) {
return <div className={styles.bar} style={{ '--progress': `${value}%` } as CSSProperties} />
}/* src/Progress.module.css */
.bar { height: 0.5rem; background: linear-gradient(to right, var(--color-brand) var(--progress), var(--color-border) 0); }TypeScript and pitfalls
Vite's client types (already referenced by the react-ts template) declare every *.module.css import as { readonly [key: string]: string }. That means imports type-check, but any key is accepted: styles.titel compiles, evaluates to undefined at runtime, and React simply renders no class. If you want typos caught, add an editor plugin such as typescript-plugin-css-modules for autocompletion, or a generator such as typed-css-modules that writes a declaration file listing each module's real class names.
The other common traps:
- Leaky element selectors.
button { }inCard.module.cssrestyles every button. Use.card button, or better, give the button its own class. - Override order across files. When a parent passes
classNameto override a child's.buttonrule, both selectors have the same specificity, so the winner is whichever stylesheet loads later. Vite emits CSS in import order, which usually favours the parent, but it is fragile. Prefer props or data attributes for real variants, and keepclassNamefor layout. - Unused classes stay. Nothing tells you a class in a module is no longer referenced. Because each module belongs to one component, at least deleting the component deletes its CSS.
composeslimits. It only works on single class selectors (not.a:hoveror.a .b), and composing from several files can make cross-file order matter again.
Practice
Style a ProductCard for a shop using CSS Modules in a Vite react-ts project:
export type Product = { id: string; name: string; priceCents: number; imageUrl: string; stock: number }- A card with an image, the product name, a formatted price and an "Add to cart" button that calls
onAddToCart(product.id). - Responsive: below 640px the image sits on top at full width; from 640px it is a 10rem-wide square beside the text.
- Hover and focus: the card's shadow grows on hover; the button darkens on hover and shows a 2px outline with an offset on keyboard focus.
- Sold out: when
stockis 0 the image turns greyscale, and the button is disabled, reads "Sold out", looks muted and does not react to hover. - Dark mode: follows the OS setting, including the page background.
- Render two cards from
App, one of them sold out.
Show solution
/* src/index.css (imported once from main.tsx) */
:root {
color-scheme: light dark;
--color-bg: #f8fafc;
--color-surface: #ffffff;
--color-border: #e5e7eb;
--color-text: #111827;
--color-text-muted: #374151;
--color-brand: #2563eb;
--color-brand-hover: #1d4ed8;
--color-focus: #2563eb;
--color-disabled-bg: #e5e7eb;
--color-disabled-text: #6b7280;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #030712;
--color-surface: #111827;
--color-border: #1f2937;
--color-text: #f9fafb;
--color-text-muted: #d1d5db;
--color-focus: #60a5fa;
--color-disabled-bg: #1f2937;
--color-disabled-text: #9ca3af;
}
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100dvh;
font-family: system-ui, sans-serif;
background: var(--color-bg);
color: var(--color-text);
}/* src/ProductCard.module.css */
.card {
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: 0.75rem;
background: var(--color-surface);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
transition: box-shadow 150ms ease;
}
.card:hover {
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
.image {
display: block;
flex-shrink: 0;
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
}
.soldOut .image {
filter: grayscale(1);
opacity: 0.7;
}
.body {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
}
.title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.price {
margin: 0;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-text-muted);
}
.button {
margin-top: auto;
padding: 0.5rem 1rem;
border: 0;
border-radius: 0.5rem;
font: inherit;
font-weight: 500;
color: white;
background: var(--color-brand);
cursor: pointer;
transition: background-color 150ms ease;
}
.button:enabled:hover {
background: var(--color-brand-hover);
}
.button:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.button:disabled {
cursor: not-allowed;
color: var(--color-disabled-text);
background: var(--color-disabled-bg);
}
@media (min-width: 40rem) {
.card { flex-direction: row; }
.image { width: 10rem; aspect-ratio: 1; }
}// src/ProductCard.tsx
import clsx from 'clsx'
import styles from './ProductCard.module.css'
export type Product = { id: string; name: string; priceCents: number; imageUrl: string; stock: number }
const price = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
type ProductCardProps = { product: Product; onAddToCart: (id: string) => void }
export function ProductCard({ product, onAddToCart }: ProductCardProps) {
const soldOut = product.stock === 0
return (
<article className={clsx(styles.card, soldOut && styles.soldOut)}>
{/* The name is right below, so the image is decorative */}
<img src={product.imageUrl} alt="" className={styles.image} />
<div className={styles.body}>
<h3 className={styles.title}>{product.name}</h3>
<p className={styles.price}>{price.format(product.priceCents / 100)}</p>
<button
type="button"
className={styles.button}
disabled={soldOut}
onClick={() => onAddToCart(product.id)}
>
{soldOut ? 'Sold out' : 'Add to cart'}
</button>
</div>
</article>
)
}/* src/App.module.css */
.list {
display: grid;
gap: 1rem;
max-width: 42rem;
margin: 0 auto;
padding: 1rem;
}
@media (min-width: 40rem) {
.list { padding: 2rem; }
}// src/App.tsx
import styles from './App.module.css'
import { ProductCard, type Product } from './ProductCard'
const products: Product[] = [
{ id: 'p1', name: 'Desk lamp', priceCents: 3900, imageUrl: 'https://picsum.photos/seed/lamp/480', stock: 4 },
{ id: 'p2', name: 'Notebook', priceCents: 650, imageUrl: 'https://picsum.photos/seed/notebook/480', stock: 0 },
]
export function App() {
return (
<main className={styles.list}>
{products.map((product) => (
<ProductCard key={product.id} product={product} onAddToCart={(id) => console.log('add', id)} />
))}
</main>
)
}Things to notice: the module never mentions dark mode, because every colour is a token from index.css that the media query swaps. The disabled look comes from the :disabled pseudo-class, and :enabled:hover keeps the sold-out button from reacting. clsx adds styles.soldOut only when needed, and .soldOut .image stays scoped because both classes are local. .body is safe as a name: it becomes _body_…, not the <body> element.
Key takeaways
- Any
*.module.cssfile is a CSS Module in Vite; importing it gives an object mapping your class names to unique generated ones. - Classes, IDs and keyframes are scoped; element selectors and custom properties are global, so anchor selectors to a local class.
- Combine classes with
clsx, use data attributes for variants, andcomposesto reuse rules inside CSS. - Keep resets and tokens in a plain global stylesheet; use
:global(...)inside a module only for classes you do not control. - Define design tokens as custom properties and redefine them for dark mode, so modules stay theme-agnostic.
- Default typings accept any key, so a typo silently yields
undefined; add a plugin or generated declarations to catch it.
Check yourself
Quiz
0/4 correct · 4 left
1.`Card.module.css` contains `.card { padding: 1rem; }` and `img { border-radius: 8px; }`. Which statement is true?
2.A component renders `<p className={styles.priceTag}>` but the module only defines `.price`. What happens?
3.What is the recommended way to make module-styled components support dark mode?
4.You want `styles.error` to also apply the rules of a `.base` class in the same module. What do you write?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
Finished reading?
Mark it complete to fill in your roadmap.