Skip to content
React rc styling 4 min read

CSS Modules

Global CSS is convenient until two components both define a .button class and silently overwrite each other. CSS Modules solve this by treating every stylesheet as a local namespace: class names are written normally but compiled to unique, hashed identifiers at build time. You import the stylesheet as a JavaScript object and reference each class through that object, so a class only applies where you explicitly use it. Vite, Next.js, and Create React App all support CSS Modules out of the box with zero configuration.

How CSS Modules work

Any file named with the .module.css suffix is treated as a CSS Module. During the build, every class selector inside it is rewritten to a unique name—typically a combination of the original name, the file name, and a hash—and the bundler hands you a mapping from your original names to the generated ones. You never type the hashed name yourself; you read it from the imported object.

Create a stylesheet next to the component that uses it:

/* Button.module.css */
.button {
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 6px;
  background: #2563eb;
  color: white;
  font-weight: 600;
  cursor: pointer;
}

.button:hover {
  background: #1d4ed8;
}

Then import it into the component as styles (any name works, but styles is the convention) and apply classes through that object:

import styles from './Button.module.css';

export default function Button({ children, onClick }) {
  return (
    <button className={styles.button} onClick={onClick}>
      {children}
    </button>
  );
}

The className receives the generated string rather than the literal "button". If you inspect the rendered element in the browser, you will see something like the following.

Output:

<button class="_button_1a2b3_1">Save</button>

Because the hash is derived from the file, a .button class in a different module compiles to a different name, so collisions become impossible.

Combining multiple classes

className is just a string, so you compose modular classes with a template literal or an array join. This keeps conditional styling readable when you toggle states such as active or disabled.

import styles from './Tab.module.css';

export default function Tab({ label, isActive, onSelect }) {
  return (
    <button
      className={`${styles.tab} ${isActive ? styles.active : ''}`}
      onClick={onSelect}
    >
      {label}
    </button>
  );
}

Tip: Dashes in class names become invalid object keys. A class written as .nav-link must be read as styles['nav-link'], so prefer camelCase names like .navLink to keep styles.navLink working with dot access.

Composing styles with composes

CSS Modules add one extension to standard CSS: the composes keyword. It lets one class inherit all declarations from another, which is a clean alternative to repeating shared rules. You can compose from the same file or from another module.

/* Card.module.css */
.base {
  border-radius: 8px;
  padding: 1rem;
  background: white;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.highlighted {
  composes: base;
  border: 2px solid #2563eb;
}

When you apply styles.highlighted, the generated class list includes both base and highlighted, so the element gets every rule from both. The component code stays unchanged—you still reference a single class.

import styles from './Card.module.css';

export default function Card({ featured, children }) {
  return (
    <div className={featured ? styles.highlighted : styles.base}>
      {children}
    </div>
  );
}

Local versus global scope

By default everything in a module is locally scoped. When you genuinely need a global selector—say, to style a third-party widget—wrap it in :global(...). Conversely, :local(...) forces local scoping inside an otherwise global context.

/* Editor.module.css */
.wrapper {
  position: relative;
}

/* Targets a class rendered by an external library */
:global(.tooltip-arrow) {
  border-color: #2563eb;
}

CSS Modules compared to alternatives

ApproachScopingRuntime costDynamic styling
Plain global CSSManual (BEM)NoneLimited
CSS ModulesAutomaticNoneVia class toggling
CSS-in-JSAutomaticSomeFull, prop-driven
Utility (Tailwind)N/ANoneVia class composition

CSS Modules sit in a sweet spot: you write real CSS with full IDE support, get automatic scoping, and pay no runtime cost because the work happens at build time.

Warning: CSS Modules scope class names, not the cascade itself. Element selectors like div { margin: 0 } inside a module still leak globally—only class, id, and animation names are localized. Stick to class selectors.

Using CSS Modules with TypeScript

Plain TypeScript does not know the shape of an imported .module.css file. Add a declaration so the styles object is typed and autocompletes:

// css-modules.d.ts
declare module '*.module.css' {
  const classes: { readonly [key: string]: string };
  export default classes;
}

Tools like typed-css-modules or the Vite plugin can generate precise per-file types if you want errors on missing class names.

Best Practices

  • Co-locate each Component.module.css beside its component so styles move and delete together with the code.
  • Use camelCase class names to keep dot access (styles.navLink) clean and avoid bracket syntax.
  • Reach for composes to share base styles instead of duplicating declarations across modules.
  • Toggle state with conditional classes rather than inline styles so all visuals stay in CSS.
  • Restrict modules to class, id, and keyframe selectors—bare element selectors are not scoped.
  • Add a *.module.css type declaration in TypeScript projects for autocomplete and safety.
Last updated June 14, 2026
Was this helpful?