Skip to main content
Saved

Pattern Styling Beginner

CSS Modules & Scoped Styles

Generating unique class names at build time keeps each component’s styles to that component, so they can’t conflict, and there’s no extra work at runtime.

Examples
React, Vue, Svelte and native web
Read
3 min

The problem

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

The pattern

With CSS Modules, the build tool takes care of naming. It turns your readable class names into unique ones, so when you write .button, it becomes .Button_button__a1b2c3 in the output, and two components can’t end up with the same class name.

Problem

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

Teams usually deal with this using BEM naming, such as .component-name__element-name--modifier. That works, but it takes discipline. Developers on your team might forget the conventions or disagree about them, and your code reviews can turn into checking everyone’s class names. Even when everyone follows BEM, the class names get very long.

Solution

You import the styles as a JavaScript module, with import styles from './Button.module.css'. The module gives you an object where styles.button contains the generated class name, and you apply that class name to your elements.

Vue’s scoped styles work in a similar way, but they 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. If you do need global styles, you can use a :global() selector.

Example

These examples show scoped styles in each framework, starting with basic usage and moving on 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

  • You and another developer can both use .header without breaking each other’s styles, because the generated names can’t conflict.
  • The build tool keeps styles isolated automatically, so your team doesn’t need to stick to a naming convention like BEM.
  • There’s no extra work at runtime, because the class names are generated at build time and no JavaScript runs to apply the 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.
  • Removing unused CSS is easier, because each component imports its styles explicitly, so a CSS module that isn’t imported anywhere is easy to spot.
  • For global styles in third-party code or browser resets, :global() lets you opt out of the scoping.

Tradeoffs

  • The browser’s developer tools show generated names like Button_button__a1b2c3, which are harder to debug without source maps.
  • To share styles, you have to compose them explicitly, because you can’t rely on global classes being available everywhere anymore.
  • Dynamic styles are harder, because CSS Modules are static, so values that change at runtime need CSS custom properties or inline styles.
  • You need a build tool such as Webpack or Vite, and it has to be configured correctly.
  • Third-party CSS frameworks such as Bootstrap ship global classes, so overriding them from a CSS module needs :global() wrappers.
  • Component libraries such as Material UI are styled with CSS-in-JS, using Emotion by default, so you usually override their styles through their own theming and sx APIs instead.
  • Combining several classes on one element is verbose, and needs a template literal or a helper such as classnames.
  • TypeScript support needs type definitions for your CSS modules, which you either generate or write manually.

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. The build tool keeps the styles isolated, so you can use everything CSS offers without breaking styles somewhere else in the app.

See also

Next pattern Debounce Debouncing waits until events stop for a short time before running a function, so a burst of rapid calls becomes a single call.

Newsletter

A monthly email
from Den Odell.

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

No spam. Unsubscribe anytime.