CSS Modules

Difficulty: Advanced

CSS Modules are a CSS file organization approach where class names are scoped locally to the component that imports them, preventing naming collisions by default. Unlike global CSS where every class name is available everywhere (and can conflict with any other class), CSS Modules generate unique, hash-based class names at build time. When you write .title in a CSS Module, the build tool transforms it into something like .ProductCard_title_x7d3k, guaranteeing uniqueness across your entire application without requiring naming conventions like BEM.

The way CSS Modules work is straightforward. You create a CSS file with a special naming convention (usually component.module.css), write normal CSS classes in it, and import the file as a JavaScript object in your component. The import returns an object where keys are your original class names and values are the generated unique class names. You then use these values as className attributes. For example, importing styles from './Card.module.css' and using styles.title gives you the hashed class name. The actual HTML will have the unique class, and the generated CSS will use matching selectors.

Composition is a powerful CSS Modules feature that allows one class to inherit styles from another. Using the composes keyword, you can compose from the same file or from other CSS Module files. This is similar to @extend in SASS but happens at the class name level rather than the selector level. When you write .primaryButton { composes: button; background: blue; }, the element receives both class names in the HTML, and each class retains its own flat selector in the CSS. This avoids the specificity problems that SASS @extend can create.

CSS Modules integrate with all major build tools and frameworks. In Create React App and Vite, files ending in .module.css are automatically treated as CSS Modules. In webpack, the css-loader with modules: true enables CSS Modules for configured file patterns. Next.js supports CSS Modules out of the box. The build tool handles generating unique class names, creating the mapping object, and outputting the transformed CSS. In development, class names are often readable (Component_className_hash), while in production they are fully minified.

CSS Modules strike a balance between the familiarity of writing normal CSS and the safety of scoped styles. You get the full power of CSS (media queries, pseudo-selectors, animations, custom properties) without learning a new syntax or runtime library. The generated styles are static CSS with zero runtime cost, unlike CSS-in-JS solutions. The main limitation is that CSS Modules only scope class names and animation names -- tag selectors, ID selectors, and global styles are not scoped. For truly global styles, CSS Modules provide the :global() escape hatch.

Code examples

Basic CSS Modules usage with React

/* Card.module.css */
.card {
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  overflow: hidden;
}

.header {
  padding: 16px;
  border-bottom: 1px solid #e5e7eb;
}

.title {
  font-size: 18px;
  font-weight: 600;
  margin: 0;
  color: #111827;
}

.body {
  padding: 16px;
  color: #4b5563;
}

.footer {
  padding: 12px 16px;
  background: #f9fafb;
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

/* ---- Component file ---- */
// Card.jsx
import styles from './Card.module.css';

function Card({ title, children, footer }) {
  return (
    <div className={styles.card}>
      <div className={styles.header}>
        <h3 className={styles.title}>{title}</h3>
      </div>
      <div className={styles.body}>{children}</div>
      {footer && (
        <div className={styles.footer}>{footer}</div>
      )}
    </div>
  );
}

/* Generated HTML (unique hashed class names): */
// <div class="Card_card_x7d3k">
//   <div class="Card_header_a2b4c">
//     <h3 class="Card_title_m9n1p">...</h3>
//   </div>
//   <div class="Card_body_q5r7s">...</div>
// </div>

You write plain CSS with simple class names. The import gives you a styles object mapping original names to unique hashed names. Using styles.title in JSX outputs the hashed class in the DOM. No naming collisions are possible because every class name is guaranteed unique.

Composition: sharing styles between classes

/* buttons.module.css */
.base {
  padding: 10px 20px;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: opacity 0.2s;
}

.base:hover {
  opacity: 0.9;
}

/* Compose from same file */
.primary {
  composes: base;
  background: #3b82f6;
  color: #fff;
}

.secondary {
  composes: base;
  background: #e5e7eb;
  color: #374151;
}

.danger {
  composes: base;
  background: #ef4444;
  color: #fff;
}

/* Compose from another file */
.iconButton {
  composes: base;
  composes: flexCenter from './layout.module.css';
  gap: 8px;
}

/* ---- Usage ---- */
// Button.jsx
import styles from './buttons.module.css';

function Button({ variant = 'primary', children }) {
  return (
    <button className={styles[variant]}>
      {children}
    </button>
  );
}

// <Button variant="primary">Save</Button>
// Renders: <button class="buttons_base_a1b2 buttons_primary_c3d4">Save</button>
// Both classes are applied - composition adds multiple class names

The composes keyword lets one class inherit styles from another. Unlike SASS @extend which duplicates selectors, composes applies multiple class names to the element. The element gets both the base class and the variant class, keeping specificity flat and styles composable.

Global styles and the :global escape hatch

/* app.module.css */

/* Local scope (default) - gets hashed */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 24px;
}

/* Global scope - NOT hashed, available everywhere */
:global(.visually-hidden) {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  border: 0;
}

/* Mix local and global */
.wrapper {
  padding: 16px;
}

/* Target a global class inside a local scope */
.wrapper :global(.third-party-widget) {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}

/* Target local class inside global context */
:global(.dark-theme) .container {
  background: #1f2937;
  color: #f9fafb;
}

/* CSS custom properties are always global (not class names) */
.theme {
  --primary: #3b82f6;
  --text: #111827;
  --bg: #ffffff;
}

/* ---- File without modules (global by default) ---- */
/* global.css - imported normally, not as module */
/* import './global.css'; */
body {
  margin: 0;
  font-family: system-ui, sans-serif;
}

* {
  box-sizing: border-box;
}

By default, all class names in a .module.css file are locally scoped. Use :global() to opt specific classes out of scoping. This is useful for utility classes, third-party library overrides, and theme classes. For global styles like resets, use a regular .css file imported without the module convention.

Dynamic class names and conditional styling

/* Alert.module.css */
.alert {
  padding: 16px;
  border-radius: 8px;
  border: 1px solid;
  display: flex;
  align-items: center;
  gap: 12px;
}

.success {
  background: #f0fdf4;
  border-color: #86efac;
  color: #166534;
}

.error {
  background: #fef2f2;
  border-color: #fca5a5;
  color: #991b1b;
}

.dismissible {
  padding-right: 40px;
  position: relative;
}

.closeButton {
  position: absolute;
  top: 12px;
  right: 12px;
  background: none;
  border: none;
  cursor: pointer;
  color: inherit;
}

/* ---- Component ---- */
// Alert.jsx
import styles from './Alert.module.css';

function Alert({ type = 'success', dismissible, children, onClose }) {
  // Method 1: Template literal
  const className = `${styles.alert} ${styles[type]} ${
    dismissible ? styles.dismissible : ''
  }`.trim();

  // Method 2: Array join
  const classNames = [
    styles.alert,
    styles[type],
    dismissible && styles.dismissible,
  ].filter(Boolean).join(' ');

  // Method 3: clsx/classnames library (recommended)
  // import clsx from 'clsx';
  // const className = clsx(styles.alert, styles[type], {
  //   [styles.dismissible]: dismissible,
  // });

  return (
    <div className={classNames}>
      {children}
      {dismissible && (
        <button
          className={styles.closeButton}
          onClick={onClose}
          aria-label="Dismiss"
        >
          &times;
        </button>
      )}
    </div>
  );
}

Dynamic class names use bracket notation (styles[type]) to select classes based on props. Conditional classes can be applied via template literals, array filtering, or the clsx/classnames utility library. The clsx library is the recommended approach for complex conditional class logic in React.

Key points

Concepts covered

CSS Modules, Scoped Styles, Composition, Hash-based Classes, Build Tools, Local Scope