CSS Modules & Scoped Styles
Scope CSS automatically to specific components to prevent global style conflicts.
In this article
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>;
} <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
- Naming conflicts go away. Two developers can both use
.headerwithout 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 andsxAPIs instead. - Class concatenation is verbose, requiring template literals or a helper like
classnamesto 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.