CSS Modules & Scoped Styles
Problem
CSS class names collide across components, causing button styles defined in one component to leak into buttons in completely unrelated components, or typography changes intended for a specific page section to affect unrelated areas across the entire application. A .header class in the navigation component conflicts with .header in table components, card components, and modal dialogs, leading developers to resort to increasingly specific selectors like .page-dashboard .widget-container .card .header or abuse !important declarations to override conflicts, making stylesheets unmaintainable and fragile. Global CSS grows into thousands of lines where changing any rule risks breaking components throughout the application, while teams attempt manual naming conventions like BEM, but enforcement requires constant vigilance during code review and developers forget or inconsistently apply the convention. Refactoring component names forces updates to dozens of CSS selectors scattered across multiple files, and third-party component libraries inject global styles that conflict with application styles, requiring defensive CSS to undo library defaults.
Solution
Automatically scope CSS class names to specific components by generating unique identifiers at build time, transforming human-readable class names like .button into unique identifiers like .Button_button__a1b2c3 that cannot collide across components. CSS Modules achieve this by processing stylesheets through a build tool that rewrites class names and provides a JavaScript object mapping original names to generated names. Vue’s scoped styles use a similar approach with attribute selectors, adding unique data attributes like data-v-f3f3eg9 to elements and their corresponding CSS rules. This prevents style collisions and eliminates naming conflicts without requiring manual conventions or runtime overhead. Components import their styles as modules, receiving an object where keys are original class names and values are the generated unique names. Composition allows sharing styles between components by explicitly importing classes from other CSS modules. Global styles remain available through :global() selectors when truly global styling is needed.
Example
This example demonstrates CSS Modules which automatically scope class names to prevent style collisions between components.
CSS Modules with React
/* Button.module.css - Styles scoped to Button component only */
.button {
background: blue; /* Won't affect other components with .button class */
padding: 10px;
border-radius: 4px;
}
.primary {
background: blue;
}
.secondary {
background: gray;
}
.large {
padding: 16px;
font-size: 18px;
}
// Import styles object with scoped class names
import styles from './Button.module.css';
function Button({ variant = 'primary', size = 'normal', children }) {
// className becomes something like "Button_button__a1b2c3" at runtime
// Multiple classes can be combined
const className = [
styles.button,
styles[variant],
size === 'large' && styles.large
].filter(Boolean).join(' ');
return <button className={className}>{children}</button>;
}
Composition - Sharing Styles Between Components
/* 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;
color: white;
}
.iconButton {
composes: baseButton from './shared.module.css';
padding: 8px;
background: transparent;
}
import styles from './Button.module.css';
// className includes both baseButton and button styles
<button className={styles.button}>Primary</button>
<button className={styles.iconButton}>🔍</button>
Global Styles When Needed
/* App.module.css */
:global(.legacy-class) {
/* This class name won't be scoped - useful for third-party integrations */
color: red;
}
.container {
/* This is scoped normally */
padding: 20px;
}
.container :global(.legacy-component) {
/* Mix scoped and global selectors */
margin: 10px;
}
Vue Scoped Styles
<template>
<button class="button primary">
Click me
</button>
</template>
<style scoped>
/* These styles only apply to this component */
/* Vue adds data-v-f3f3eg9 attribute to elements and CSS rules */
.button {
background: blue;
padding: 10px;
}
.primary {
background: blue;
}
/* Deep selector affects child components */
.button :deep(.icon) {
margin-right: 8px;
}
</style>
Vue with CSS Modules
<template>
<button :class="$style.button">
Click me
</button>
</template>
<style module>
.button {
background: blue;
padding: 10px;
}
</style>
<script>
export default {
mounted() {
// Access module classes in JavaScript
console.log(this.$style.button); // "button_1a2b3c"
}
}
</script>
Svelte Scoped Styles
<script>
export let variant = 'primary';
</script>
<button class="button {variant}">
Click me
</button>
<style>
/* Styles automatically scoped to this component */
/* Svelte generates unique class names at compile time */
.button {
padding: 10px;
border-radius: 4px;
}
.primary {
background: blue;
}
.secondary {
background: gray;
}
/* :global() for unscoped styles */
:global(body) {
margin: 0;
}
</style>
Next.js CSS Modules
// components/Card.js
import styles from './Card.module.css';
export default function Card({ title, children }) {
return (
<div className={styles.card}>
<h2 className={styles.title}>{title}</h2>
<div className={styles.content}>{children}</div>
</div>
);
}
/* Card.module.css */
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
.title {
margin: 0 0 12px 0;
font-size: 20px;
}
.content {
color: #666;
}
TypeScript Support
// Button.module.css.d.ts (auto-generated or manual)
declare const styles: {
readonly button: string;
readonly primary: string;
readonly secondary: string;
readonly large: string;
};
export default styles;
// Button.tsx with type safety
import styles from './Button.module.css';
// TypeScript knows what classes are available
<button className={styles.button}>Click</button>
// TypeScript error if class doesn't exist
<button className={styles.nonexistent}>Error</button>
Dynamic Class Names with classnames Library
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 Custom Properties
/* theme.module.css */
.lightTheme {
--primary-color: blue;
--background-color: white;
--text-color: black;
}
.darkTheme {
--primary-color: lightblue;
--background-color: #1a1a1a;
--text-color: white;
}
/* Button.module.css */
.button {
background: var(--primary-color);
color: var(--text-color);
padding: 10px;
}
import themeStyles from './theme.module.css';
import buttonStyles from './Button.module.css';
function App({ theme }) {
return (
<div className={theme === 'dark' ? themeStyles.darkTheme : themeStyles.lightTheme}>
<button className={buttonStyles.button}>Themed Button</button>
</div>
);
}
Webpack 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 Configuration
// vite.config.js
export default {
css: {
modules: {
localsConvention: 'camelCase',
scopeBehaviour: 'local',
generateScopedName: '[name]__[local]___[hash:base64:5]'
}
}
};
Benefits
- Prevents style collisions by automatically scoping class names at build time, eliminating the entire class of bugs caused by naming conflicts.
- Eliminates need for strict naming conventions like BEM that require manual enforcement and add verbosity to class names.
- Works with standard CSS without learning new syntax - developers can write normal CSS and get scoping automatically.
- Build-time scoping has zero runtime performance cost - no JavaScript runs to apply styles, just statically generated unique class names.
- Still allows global styles when explicitly needed through
:global()selectors for cases like third-party integration or truly global resets. - Makes refactoring safer - renaming components or moving files doesn’t require updating CSS selectors since imports handle the relationship.
- Enables dead code elimination - unused CSS modules can be identified and removed since imports make dependencies explicit.
- Composition feature allows sharing common styles across components without CSS cascade or inheritance complexity.
Tradeoffs
- Generated class names make debugging harder without source maps - inspecting elements in DevTools shows
Button_button__a1b2c3instead of readable.buttonnames. - Sharing styles between components requires explicit composition with
composeskeyword or importing modules, adding verbosity compared to global classes. - Cannot dynamically compute styles based on props or state - CSS Modules are static, requiring either CSS custom properties or inline styles for dynamic values.
- Requires build tool configuration and support - projects need Webpack, Vite, Parcel, or framework-specific tooling configured correctly for CSS Modules to work.
- May complicate integration with third-party CSS frameworks like Bootstrap or Material UI that rely on global class names, requiring
:global()wrappers. - Class name concatenation for multiple classes is more verbose than space-separated strings, requiring template literals or helper libraries like
classnames. - TypeScript support requires either auto-generation of type definitions or manual typing, adding build complexity or maintenance burden.
- Pseudo-selectors and media queries work normally but nested selectors can become complex when combining scoped and global selectors.
- Some CSS features like
@keyframesanimations require special handling with:global()or explicit naming to avoid scoping issues. - Framework-specific implementations vary - React CSS Modules, Vue scoped styles, and Svelte scoping have subtle differences in behavior and edge cases.
- Server-side rendering requires careful configuration to ensure CSS Modules work correctly with hydration and avoid class name mismatches.
- Learning curve for
composesand:global()syntax adds complexity for developers new to CSS Modules despite the overall simplicity. - Large applications may generate thousands of unique class names that bloat HTML size slightly, though gzip compression mitigates this effectively.