Webpack CSS Modules let you write CSS that’s locally scoped to your JavaScript components, preventing style collisions. The most surprising thing is that the "scoping" isn’t actually a browser feature; it’s a JavaScript-powered transformation that happens during your build.

Let’s see it in action.

Imagine this Button.js component:

import React from 'react';
import styles from './Button.module.css'; // Import CSS Modules

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

export default Button;

And its corresponding Button.module.css:

.button {
  background-color: blue;
  color: white;
  padding: 10px 20px;
  border: none;
  border-radius: 5px;
  cursor: pointer;
}

.button:hover {
  background-color: darkblue;
}

When Webpack processes this, it transforms the CSS into something like this (conceptually):

// Button.module.css transformed by Webpack
var styles = {
  "button": "Button_button__1a2b3c" // Example generated class name
};
export default styles;

And the Button.js component effectively becomes:

import React from 'react';
import styles from './Button.module.css'; // styles is now { button: 'Button_button__1a2b3c' }

function Button({ children, onClick }) {
  return (
    <button className={styles.button} onClick={onClick}> {/* className becomes "Button_button__1a2b3c" */}
      {children}
    </button>
  );
}

export default Button;

The actual CSS output in your final bundle might look like:

.Button_button__1a2b3c {
  background-color: blue;
  color: white;
  padding: 10px 20px;
  border: none;
  border-radius: 5px;
  cursor: pointer;
}

.Button_button__1a2b3c:hover {
  background-color: darkblue;
}

Webpack, via loaders like css-loader configured with the modules: true option, intercepts the .module.css file. It parses the CSS, generates unique, often hash-based class names (like Button_button__1a2b3c), and creates a JavaScript object mapping your original class names (.button) to these generated ones. This object is then exported and imported into your JavaScript component.

This process gives you several benefits:

  1. Local Scope: Styles defined in Button.module.css only apply to elements that explicitly use styles.button (or whatever your mapped class is). They won’t accidentally affect a .button class in another component’s CSS.
  2. No Naming Collisions: Because class names are generated uniquely (often with a hash of the file path and class name), you can use simple, semantic names like .button or .title without worrying about conflicts, even across different files.
  3. Dead Code Elimination: If a CSS class is defined but never used by any component importing its corresponding module, Webpack’s tree-shaking can potentially remove it from the final CSS bundle.

The core configuration in your webpack.config.js for this involves css-loader. You’ll typically see it like this within your module.rules:

{
  test: /\.module\.css$/,
  use: [
    'style-loader', // or MiniCssExtractPlugin.loader
    {
      loader: 'css-loader',
      options: {
        modules: {
          // Configuration for how class names are generated
          localIdentName: '[name]_[local]_[hash:base64:5]', // Example: Button_button_aBcDe
        },
        importLoaders: 1, // If using postcss-loader or sass-loader before css-loader
      },
    },
    // 'postcss-loader', // Optional: for autoprefixing, etc.
  ],
}

The localIdentName option is crucial. It defines the pattern for how your original class names are transformed into unique identifiers. The example [name]_[local]_[hash:base64:5] means it will produce something like Button_button_aBcDe, where Button is the module name, button is your original class name, and aBcDe is a short hash.

You can also compose styles. If you have a base.module.css with a .baseButton class and want to extend it in primary.module.css, you can do:

primary.module.css:

@import './base.module.css';

.primaryButton {
  composes: baseButton from './base.module.css'; /* Inherit styles */
  background-color: green;
}

PrimaryButton.js:

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

// ...
<button className={styles.primaryButton}>...</button>
// ...

This will result in a generated class name that includes both the baseButton styles and the .primaryButton overrides.

The css-loader itself doesn’t inject styles into the DOM; it just transforms the CSS and creates the JavaScript mapping. You need another loader like style-loader (for development, injecting styles via <style> tags) or mini-css-extract-plugin.loader (for production, extracting CSS into separate .css files) to actually get the styles into your page. The importLoaders: 1 option tells css-loader to run preceding loaders (like postcss-loader or sass-loader) if they exist before css-loader processes the CSS, which is common when you’re using preprocessors or postprocessors.

When you need to apply multiple CSS Modules classes to a single element, you can combine them using template literals or clsx/classnames libraries:

import React from 'react';
import styles from './MyComponent.module.css';

function MyComponent({ isActive }) {
  const activeClass = isActive ? styles.active : '';
  return (
    <div className={`${styles.container} ${activeClass}`}> {/* Combining classes */}
      Some content
    </div>
  );
}

The most common pitfall is forgetting the .module.css extension. If you name your file Button.css instead of Button.module.css, css-loader will treat it as global CSS by default, and you’ll lose the local scoping. You can, however, configure css-loader to enable modules for all CSS files, but the .module.css convention is the standard and recommended way.

Once you’ve mastered local scoping, the next logical step is exploring how to manage global styles or leverage CSS-in-JS solutions that offer similar benefits with different trade-offs.

Want structured learning?

Take the full Webpack course →