CSS Modules & Scoped Styles
Problem
You name a class .header. I name a class .header. Someone installs a component library that also has .header. Chaos ensues.
I’ve debugged CSS issues where button padding mysteriously 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 becomes CSS police duty. Even with perfect BEM compliance, your class names are absurdly long.
Solution
Let the build tool handle naming. CSS Modules automatically transform your readable class names into unique identifiers. You write .button, it becomes .Button_button__a1b2c3 in the output. Collisions become impossible.
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. 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 implementation differs, but the goal is the same: styles that can’t leak. When you genuinely need global styles, use :global() selectors.
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>
export let variant = 'primary';
</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 vanish. Two developers can both use
.headerand nothing breaks. - 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.
- Normal CSS syntax. You’re not learning a new language, just writing CSS with automatic scoping.
- Refactoring becomes safe because styles follow through 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; use CSS custom properties or inline styles for runtime values.
- Build tool configuration required: you need Webpack, Vite, or Parcel set up correctly.
- Third-party CSS frameworks assume global classes, so Bootstrap or Material UI integration requires
:global()wrappers. - Class concatenation is verbose, requiring template literals or a helper like
classnamesto combine multiple classes. - TypeScript support needs type definitions for your CSS modules. Either generate them or type manually.
Summary
CSS Modules solve the global namespace problem by generating unique class names that can’t collide. Write styles in plain CSS files, import them into components, and let the tooling handle isolation. This gives you the full power of CSS without fear of breaking styles elsewhere.