Skip to main content
Saved

CSS Modules & Scoped Styles

Category Styling

Level Beginner

Examples

React, Vue, Svelte, Native web

Scope CSS automatically to specific components to prevent global style conflicts.

In this article
  1. Problem
  2. Solution
  3. Example
  4. Basic Scoped Styles
  5. Composition
  6. Global Styles
  7. TypeScript Support
  8. Dynamic Classes with classnames
  9. Theming with CSS Variables
  10. Build Tool Configuration
  11. Benefits
  12. Tradeoffs
  13. Summary
  14. See also

CSS Modules & Scoped Styles

Problem

You name a class .header, I name a class .header, and someone installs a component library that also has .header. Now the rules from all three apply to each other’s elements.

I’ve debugged CSS issues where button padding changed because a developer on another team added a .button class in a completely different feature. Global CSS is a minefield, where any change can break something three pages away.

The usual fix is BEM naming: .component-name__element-name--modifier. It works, but requires discipline. Developers forget, take shortcuts, or disagree on conventions, and code review turns into checking everyone’s class names. Even when everyone follows BEM, the class names get very long.

Solution

With CSS Modules, the build tool handles naming. It transforms your readable class names into unique identifiers: you write .button, and it becomes .Button_button__a1b2c3 in the output, so two components can’t end up with the same class name.

You import styles as a JavaScript module: import styles from './Button.module.css'. The module gives you an object where styles.button contains the generated class name. You apply it to your elements and you’re done.

Vue’s scoped styles work similarly but use data attributes instead of renamed classes; Svelte generates unique class names at compile time. The implementations differ, but all of them keep a component’s styles from leaking out to the rest of the page. When you do need global styles, :global() selectors provide them.

Example

Here’s how scoped styles work across frameworks, from basic usage to composition and theming.

Basic Scoped Styles

/* Button.module.css */
.button {
  padding: 10px;
  border-radius: 4px;
}

.primary { background: blue; }
.secondary { background: gray; }
.large { padding: 16px; font-size: 18px; }
import styles from './Button.module.css';

function Button({ variant = 'primary', size = 'normal', children }) {
  const className = [
    styles.button,
    styles[variant],
    size === 'large' && styles.large
  ].filter(Boolean).join(' ');

  return <button className={className}>{children}</button>;
}

Composition

/* shared.module.css */
.baseButton { padding: 10px; border: none; border-radius: 4px; cursor: pointer; }
/* Button.module.css */
.button {
  composes: baseButton from './shared.module.css';
  background: blue;
}

.iconButton {
  composes: baseButton from './shared.module.css';
  background: transparent;
}
import styles from './Button.module.css';

<button className={styles.button}>Primary</button>
<button className={styles.iconButton}>Icon</button>

Global Styles

/* App.module.css */
:global(.legacy-class) { color: red; }

.container { padding: 20px; }
.container :global(.legacy-component) { margin: 10px; }

TypeScript Support

// Button.module.css.d.ts (auto-generated or manual)
declare const styles: {
  readonly button: string;
  readonly primary: string;
};
export default styles;
import styles from './Button.module.css';

<button className={styles.button}>Click</button>
<button className={styles.nonexistent}>Error</button> // TS error

Dynamic Classes with classnames

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

function Button({ variant, size, disabled, children }) {
  const buttonClass = classNames(styles.button, styles[variant], {
    [styles.large]: size === 'large',
    [styles.disabled]: disabled
  });
  return <button className={buttonClass}>{children}</button>;
}

Theming with CSS Variables

/* theme.module.css */
.light { --primary: blue; --bg: white; }
.dark { --primary: lightblue; --bg: #1a1a1a; }
import theme from './theme.module.css';
import styles from './Button.module.css';

function App({ isDark }) {
  return (
    <div className={isDark ? theme.dark : theme.light}>
      <button className={styles.button}>Themed</button>
    </div>
  );
}

Build Tool Configuration

// webpack.config.js
module.exports = {
  module: {
    rules: [{
      test: /\.module\.css$/,
      use: ['style-loader', {
        loader: 'css-loader',
        options: { modules: { localIdentName: '[name]__[local]___[hash:base64:5]' } }
      }]
    }]
  }
};

// vite.config.js
export default {
  css: {
    modules: { localsConvention: 'camelCase', generateScopedName: '[name]__[local]___[hash:base64:5]' }
  }
};

Benefits

  • Naming conflicts go away. Two developers can both use .header without breaking each other’s styles.
  • No manual conventions required; the build tool enforces isolation automatically, so you don’t need BEM discipline.
  • Zero runtime cost since everything happens at build time with no JavaScript running to apply styles.
  • It’s normal CSS syntax with automatic scoping, so there’s no new language to learn.
  • Refactoring is safer, because styles move with their imports when you move files or rename components.
  • Dead code elimination works since unused CSS modules are easy to identify through explicit dependencies.
  • :global() escapes when you need it for third-party integration or browser resets.

Tradeoffs

  • DevTools shows ugly generated names like Button_button__a1b2c3, which is harder to debug without source maps.
  • Sharing styles requires explicit composition. You can no longer rely on global classes being available everywhere.
  • Dynamic styles are tricky since CSS Modules are static, so runtime values need CSS custom properties or inline styles.
  • It needs a build tool such as Webpack, Vite, or Parcel, set up correctly.
  • Third-party CSS frameworks like Bootstrap ship global classes, so overriding them from a CSS module requires :global() wrappers. Component libraries like Material UI style themselves with CSS-in-JS (Emotion by default), so overrides usually go through their own theming and sx APIs instead.
  • Class concatenation is verbose, requiring template literals or a helper like classnames to combine multiple classes.
  • TypeScript support needs type definitions for your CSS modules, which you either generate or write by hand.

Summary

CSS Modules solve the global namespace problem by generating unique class names that can’t collide. You write styles in plain CSS files and import them into components, and the tooling handles isolation, so you can use all of CSS without breaking styles elsewhere.

See also

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.