Skip to content

CSS Modules and Scoping

advanced10 min read

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.

Mental Model

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;
}
Common Trap

: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 WhenDon't Use When
Multi-developer team needs scope safetyYou're using Tailwind (it handles scoping differently)
Component-based architectureYou need runtime dynamic styles
You want CSS file co-location with componentsYou 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
Quiz
In a CSS Module, you write .card :global(.badge) { color: red; }. What gets hashed?
Execution Trace
Write
.card { padding: 1.5rem; } in Card.module.css
Author writes normal CSS
Import
import styles from './Card.module.css'
Bundler processes the import
Hash
.card → .Card_card_a7x2k
Class names made unique via hashing
Object
styles = { card: 'Card_card_a7x2k' }
JavaScript gets a mapping object
Render
className=`{styles.card}` → class='Card_card_a7x2k'
Scoped class applied in HTML
What developers doWhat 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)
Quiz
You write .title { } in Card.module.css and Header.module.css. What happens?
Quiz
What does composes do in CSS Modules?
Key Rules
  1. 1CSS Modules scope class names automatically via hashing — no naming convention required
  2. 2Always use styles.className from the import, never string literals
  3. 3Use :global sparingly — it disables scoping and should be rare
  4. 4composes adds classes to the HTML class list — it doesn't duplicate CSS
  5. 5CSS Modules are zero-runtime: all scoping happens at build time