CSS Modules and Scoping
Scoping That Actually Works
CSS has a global namespace by default. Every class name is visible to every element. CSS Modules solve this at the tooling level: each .module.css file's class names are automatically hashed to unique strings, making collisions impossible. You import styles as a JavaScript object, and the bundler handles the mapping.
Think of CSS Modules as automatic namespacing. You write .card in your file. The bundler outputs .card_a7x2k — a unique hash that only your component knows about. Other components can also have .card in their files — they get different hashes. It's like every CSS file has its own private namespace, enforced by the build tool.
How CSS Modules Work
Writing a Module
/* Card.module.css */
.card {
padding: 1.5rem;
border-radius: 8px;
background: var(--bg-surface);
}
.title {
font-size: 1.25rem;
font-weight: 600;
}
.image {
border-radius: 8px 8px 0 0;
width: 100%;
}
Importing in JavaScript/React
import styles from './Card.module.css';
export function Card({ title, image }) {
return (
<div className={styles.card}>
<img className={styles.image} src={image} alt="" />
<h3 className={styles.title}>{title}</h3>
</div>
);
}
What the Browser Sees
<div class="Card_card_a7x2k">
<img class="Card_image_b3y1m" src="..." alt="" />
<h3 class="Card_title_c8z4n">Title</h3>
</div>
The hash ensures uniqueness. Your .title never conflicts with another file's .title.
:global for Escape Hatches
Sometimes you need to target non-module classes (third-party components, HTML elements within your component):
/* Card.module.css */
.card {
padding: 1.5rem;
}
/* Target a global class inside the scoped component */
.card :global(.external-badge) {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
/* Target bare HTML elements (already global) */
.card p {
margin-bottom: 1rem;
}
/* Entire block as global */
:global(.page-transition) {
opacity: 0;
transition: opacity 0.3s;
}
:global disables hashing for the selectors it wraps. Overusing :global defeats the purpose of CSS Modules. If you find yourself using :global frequently, you're probably fighting the tool. Consider whether those styles belong in a global stylesheet instead.
Composition with composes
CSS Modules support composing styles from other classes:
/* shared.module.css */
.truncate {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.elevated {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Card.module.css */
.title {
composes: truncate from './shared.module.css';
font-size: 1.25rem;
}
.card {
composes: elevated from './shared.module.css';
padding: 1.5rem;
}
/* Same-file composition */
.base {
padding: 0.75rem 1.5rem;
border-radius: 8px;
}
.primary {
composes: base;
background: var(--color-primary);
color: white;
}
composes adds the composed class to the element's class list in the HTML output — it doesn't duplicate the CSS rules.
CSS Modules in Next.js
Next.js has built-in CSS Modules support:
// app/components/Button.tsx
import styles from './Button.module.css';
import clsx from 'clsx';
type ButtonProps = {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
};
export function Button({ variant = 'primary', size = 'md', children }: ButtonProps) {
return (
<button
className={clsx(
styles.button,
styles[variant],
styles[size]
)}
>
{children}
</button>
);
}
/* Button.module.css */
.button {
border: none;
cursor: pointer;
font-weight: 500;
transition: background 0.15s;
}
.primary { background: var(--color-primary); color: white; }
.secondary { background: var(--bg-surface); color: var(--text); }
.ghost { background: transparent; color: var(--color-primary); }
.sm { padding: 0.375rem 0.75rem; font-size: 0.875rem; }
.md { padding: 0.625rem 1.25rem; font-size: 1rem; }
.lg { padding: 0.75rem 1.5rem; font-size: 1.125rem; }
When to Use CSS Modules
| Use CSS Modules When | Don't Use When |
|---|---|
| Multi-developer team needs scope safety | You're using Tailwind (it handles scoping differently) |
| Component-based architecture | You need runtime dynamic styles |
| You want CSS file co-location with components | You prefer CSS-in-JS for prop-based styling |
| You want zero-runtime CSS (no JS overhead) | Simple static sites with minimal CSS |
CSS Modules vs Alternatives
CSS Modules: Scoped at build time, zero runtime, real CSS files
Tailwind: Utility classes in HTML, no custom CSS files
CSS-in-JS: Runtime style injection, full JS dynamism, bundle cost
Vanilla CSS: Global scope, requires naming discipline
| What developers do | What they should do |
|---|---|
| Using string class names instead of the imported styles object className='card' uses the unhashed name. Only styles.card uses the scoped, hashed name. | Always use styles.className — string literals bypass the module system |
| Overusing :global to style child components :global defeats scoping. Prop-based className lets the parent customize children safely. | Pass className props to child components, or use composition |
| Creating deeply nested selectors in CSS Modules CSS Modules eliminate the need for nesting to prevent collisions. Flat selectors = lower specificity = easier overrides. | Keep selectors flat — the scoping hash already prevents conflicts |
| Using CSS Modules and Tailwind together on the same elements Mixing CSS Module classes and Tailwind utilities on the same element creates confusion about where styles come from | Pick one approach per project (or clearly separate their domains) |
- 1CSS Modules scope class names automatically via hashing — no naming convention required
- 2Always use styles.className from the import, never string literals
- 3Use :global sparingly — it disables scoping and should be rare
- 4composes adds classes to the HTML class list — it doesn't duplicate CSS
- 5CSS Modules are zero-runtime: all scoping happens at build time