# CSS Modules

> Style React components with locally scoped CSS Modules in Vite, compose and combine classes, and theme them with custom properties.

Frontend Developer roadmap · TypeScript & React · 17 min read · https://levelcode.org/roadmaps/frontend/css-modules

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:

```bash
npm create vite@latest shop -- --template react-ts
cd shop
npm install
```

Write a module next to its component:

```css
/* 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:

```tsx
// 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:

```text
{ 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:

```ts
// 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:

```tsx
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:

```css
/* 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); }
```

```tsx
<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:

```tsx
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:

```css
/* 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`:

```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:

```css
/* 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:

```css
/* 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:

```tsx
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} />
}
```

```css
/* 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.

> [!WARNING]
> A component that "has no styles" is usually a misspelled key or a class that does not exist in the module. Log `styles` or inspect the element: a missing `class` attribute means you read `undefined`.

The other common traps:

- **Leaky element selectors.** `button { }` in `Card.module.css` restyles every button. Use `.card button`, or better, give the button its own class.
- **Override order across files.** When a parent passes `className` to override a child's `.button` rule, 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 keep `className` for 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.
- **`composes` limits.** It only works on single class selectors (not `.a:hover` or `.a .b`), and composing from several files can make cross-file order matter again.

> [!TIP]
> In DevTools, the generated name tells you where a rule came from: `_price_1k3z9_14` is the `.price` class on line 14 of whichever module has that hash. Click the stylesheet link in the Styles pane to jump straight to the source.

## Practice

Style a `ProductCard` for a shop using CSS Modules in a Vite `react-ts` project:

```ts
export type Product = { id: string; name: string; priceCents: number; imageUrl: string; stock: number }
```

1. A card with an image, the product name, a formatted price and an "Add to cart" button that calls `onAddToCart(product.id)`.
2. **Responsive:** below 640px the image sits on top at full width; from 640px it is a 10rem-wide square beside the text.
3. **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.
4. **Sold out:** when `stock` is 0 the image turns greyscale, and the button is disabled, reads "Sold out", looks muted and does not react to hover.
5. **Dark mode:** follows the OS setting, including the page background.
6. Render two cards from `App`, one of them sold out.

**Solution**

```css
/* 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);
}
```

```css
/* 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; }
}
```

```tsx
// 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>
  )
}
```

```css
/* src/App.module.css */
.list {
  display: grid;
  gap: 1rem;
  max-width: 42rem;
  margin: 0 auto;
  padding: 1rem;
}

@media (min-width: 40rem) {
  .list { padding: 2rem; }
}
```

```tsx
// 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.css` file 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, and `composes` to 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.

## Quiz

1. `Card.module.css` contains `.card { padding: 1rem; }` and `img { border-radius: 8px; }`. Which statement is true?

   A. Both rules apply only inside the Card component
   B. Neither rule applies until you use :global
   C. The .card rule is scoped, but every <img> in the whole app gets rounded corners
   D. Vite throws an error because element selectors are not allowed in modules

   **Answer: C.** CSS Modules rename class names, IDs and keyframes. Element selectors are left untouched, so a bare `img` rule is global. Write `.card img` to keep it contained.

2. A component renders `<p className={styles.priceTag}>` but the module only defines `.price`. What happens?

   A. TypeScript reports a compile error with the default Vite types
   B. styles.priceTag is undefined, so the paragraph renders with no class and no styles
   C. Vite falls back to the closest matching class, .price
   D. The build fails with a missing class error

   **Answer: B.** Vite types module imports as an index signature of strings, so any key type-checks. At runtime the key does not exist, the value is undefined and React omits the attribute. A generated declaration file or editor plugin catches this.

3. What is the recommended way to make module-styled components support dark mode?

   A. Use colours from custom properties defined in a global stylesheet and redefine them under prefers-color-scheme: dark
   B. Write a :global(.dark) override for every class in every module
   C. Create a second Card.dark.module.css and import it conditionally
   D. Pass a darkMode prop to every component and switch class names

   **Answer: A.** Custom properties are global and inherited, so tokens defined once on :root can switch for the whole app. Modules reference var(--color-surface) and never need to know which theme is active.

4. You want `styles.error` to also apply the rules of a `.base` class in the same module. What do you write?

   A. `.error { @extend .base; }`
   B. `.error { @apply base; }`
   C. `.error { composes: base; }`
   D. `.error, .base { }`

   **Answer: C.** `composes` adds the generated name of `.base` to the class list of `styles.error`. `@extend` is Sass, `@apply` is Tailwind, and grouping selectors would change `.base` too without linking the two in JavaScript.
