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>;
} <template>
<button class="button primary">Click me</button>
</template>
<style scoped>
/* Vue adds data-v-xxx attribute to scope these rules */
.button { padding: 10px; }
.primary { background: blue; }
/* Deep selector affects child components */
.button :deep(.icon) { margin-right: 8px; }
</style> <template>
<button :class="$style.button">Click me</button>
</template>
<style module>
.button { background: blue; padding: 10px; }
</style>
<script>
export default {
mounted() {
console.log(this.$style.button); // "button_1a2b3c"
}
}
</script> <script>
let { variant = 'primary' } = $props();
</script>
<button class="button {variant}">Click me</button>
<style>
/* Svelte scopes these automatically */
.button { padding: 10px; border-radius: 4px; }
.primary { background: blue; }
.secondary { background: gray; }
:global(body) { margin: 0; }
</style> 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
.headerwithout 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
sxAPIs 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.