esbuild 13 ⚡ CSS Modules — Local scoping for component styles
CSS Modules are enabled in esbuild by the filename convention, not by a plugin. A file ending in .module.css uses the local-css loader by default, which scopes every class name and ID to that file and exports a mapping from the original names to the generated ones . A plain .css file uses the css loader, which leaves class names global and does not export anything. The scoping is the entire point: two components can both define a .button class, and the generated names will not collide.
This chapter covers the local-css loader, the composes annotation that references other classes, the :global and :local escape hatches, and the difference between the css loader and the local-css loader.
Key point: The .module.css suffix is the activation switch. A file with that suffix is a CSS Module; a file without it is global CSS. The local-css loader scopes the names and exports the mapping, while the css loader passes :local and :global annotations through unchanged .
Why CSS Modules are built into esbuild
The plugin problem. Before esbuild 0.18.14, CSS Modules required a third-party plugin. The plugins worked, but they added a dependency, a configuration layer, and a performance cost. The built-in local-css loader removes the plugin and the configuration, and it integrates with esbuild’s bundler so that the scoping and the export generation happen in the same pass as the JavaScript bundling .
The collision problem. A global .button class in header.css applies to every element with class="button" on the page, including the button in footer.css that was supposed to be red. The local-css loader renames the class to something unique per file, such as app_outerShell for .outerShell in app.css . The two files can both define .button, and the two rules will not override each other.
The export problem. The scoping is only useful if the JavaScript can reference the generated name. The local-css loader generates one export per local name, and the JavaScript import receives an object that maps the original names to the generated ones . The component uses styles.button, and the actual class name in the DOM is the generated one.
a. The local-css loader and the filename convention
The .module.css suffix is the convention that the loader uses by default. A file named button.module.css is processed by the local-css loader, and a file named button.css is processed by the css loader .
/* button.module.css */
.button {
background: blue;
}
import styles from './button.module.css'
// styles.button is the generated class name
element.className = styles.button
The generated name is an implementation detail. The documentation states that the names are not intended to be hard-coded anywhere, and that the only way to reference them is through the import statement . The name generation changes when --minify is enabled, which is why the import is the only stable reference.
The loader can also be configured explicitly for other extensions. A project that uses .module.scss can map the extension to local-css with the loader option, or a project that wants all .css files to be global except the ones with the .module segment can use the default configuration.
b. The composes annotation
The composes annotation is the mechanism for a class to inherit the declarations of another class. It is part of the CSS Modules specification, and esbuild implements it as of 0.18.19 .
/* style.css */
.button {
display: inline-block;
}
.submit {
composes: button;
font-weight: bold;
}
The submit class composes the button class, which means the element with the submit class receives both the display: inline-block from button and the font-weight: bold from submit. The composes annotation can also reference a class from another file with the from syntax.
.button {
composes: pulse from "anim.css";
}
The composes annotation is not a substitute for a cascade. The specification deliberately leaves the order of the composed classes undefined, and the documentation warns against declaring the same property in two composed classes . The annotation is for composing non-overlapping declarations, not for overriding.
c. The :global and :local escape hatches
A file processed by the local-css loader has local names by default. A rule that must remain global can be wrapped in :global().
/* button.module.css */
:global(.external-widget) {
margin: 0;
}
.button {
background: blue;
}
The :global(.external-widget) selector is not scoped, and the .external-widget name is preserved. The .button selector remains scoped. The :local() selector is the reverse: it forces a name to be local in a file that uses the global-css loader .
The css loader is different from both. It passes the :local and :global annotations through unchanged, which means a file processed by the css loader does not get the scoping behavior at all. The annotations are preserved for a downstream tool, not interpreted by esbuild .
Complete Example Session
/* ============================================
PART 1: THE CSS MODULE FILE
============================================ */
/* button.module.css */
.button {
background: blue;
padding: 8px 16px;
}
// ============================================
// PART 2: THE IMPORT AND USAGE
// ============================================
import styles from './button.module.css'
// styles.button is the generated name
element.className = styles.button
/* ============================================
PART 3: THE OUTPUT CSS
============================================ */
/* The class name is scoped to the file. */
.button_a1b2c {
background: blue;
padding: 8px 16px;
}
// ============================================
// PART 4: THE EXPORTED MAP
// ============================================
// The import receives:
// { button: 'button_a1b2c' }
/* ============================================
PART 5: THE COMPOSES ANNOTATION
============================================ */
/* style.css */
.button {
display: inline-block;
}
.submit {
composes: button;
font-weight: bold;
}
// ============================================
// PART 6: THE COMPOSED OUTPUT
// ============================================
// The submit element receives both classes.
// The generated class names are combined.
/* ============================================
PART 7: THE GLOBAL ESCAPE HATCH
============================================ */
:global(.external-widget) {
margin: 0;
}
.button {
background: blue;
}
/* ============================================
PART 8: THE GLOBAL OUTPUT
============================================ */
.external-widget {
margin: 0;
}
.button_a1b2c {
background: blue;
}
/* ============================================
PART 9: THE GLOBAL CSS FILE
============================================ */
/* global.css */
.button {
background: red;
}
/* The class name is not scoped. */
# ============================================
# PART 10: THE EXPLICIT LOADER CONFIGURATION
# ============================================
esbuild app.js --bundle --loader:.css=global-css --loader:.module.css=local-css
The ten parts covered the module file, the import, the output CSS, the exported map, the composes annotation, the composed output, the :global escape hatch, the global output, the global CSS file, and the explicit loader configuration.
Quick Reference
Loaders
| Extension | Loader | Behavior |
|---|---|---|
.css | css | Global, :local/:global passed through |
.module.css | local-css | Scoped, exports class map |
.css (explicit) | global-css | Global, :local() supported |
Scoping annotations
| Annotation | Behavior |
|---|---|
.class (in local-css) | Scoped |
:global(.class) | Not scoped |
:local(.class) | Scoped (in global-css) |
The composes annotation
| Syntax | Purpose |
|---|---|
composes: other; | Compose a local class |
composes: other from "file.css"; | Compose a class from another file |
Import behavior
| Import | Result |
|---|---|
import styles from './x.module.css' | Class map object |
import './x.module.css' | Side effect only (styles applied) |
Best Practices
✅ Do This:
/* Use the .module.css suffix for scoped styles */
/* button.module.css */ // ✅
// Reference the class through the import map
element.className = styles.button // ✅
/* Use :global for third-party class names */
:global(.external-widget) { margin: 0; } // ✅
/* Use composes for non-overlapping declarations */
.submit { composes: button; font-weight: bold; } // ✅
❌ Don’t Do This:
/* Use a plain .css file for scoped styles */
/* button.css */ // not scoped // ❌
// Hardcode the generated class name
element.className = 'button_a1b2c' // ❌
/* Compose classes that declare the same property */
.a { color: red; }
.b { composes: a; color: blue; } // undefined order // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
Class is undefined | Filename missing .module | Rename to *.module.css |
| Generated name changes on build | Hardcoded the name | Use the import map |
:global has no effect | Using the css loader | Use local-css or global-css |
| Composed property is overridden | Both classes declare the same property | Compose only non-overlapping declarations |
| Scoping not applied | Loader configured as css | Check the loader configuration |
Real-World Examples
1. Module file
/* button.module.css */
.button { background: blue; }
2. Import and use
import styles from './button.module.css'
element.className = styles.button
3. The export map
// { button: 'button_a1b2c' }
4. The composes annotation
.submit { composes: button; font-weight: bold; }
5. The global escape hatch
:global(.external-widget) { margin: 0; }
6. The local escape hatch
:local(.scoped) { color: red; }
7. The global file
/* global.css */
.button { background: red; }
8. The explicit loader
esbuild app.js --bundle --loader:.module.css=local-css
9. The import from another file
.button { composes: pulse from "anim.css"; }
10. The side-effect import
import './global.css'
Visual
The scoping mechanism
┌─────────────────────────────────────────────────────────────┐
│ button.module.css │
│ .button { background: blue; } │
│ │ │
│ ▼ │
│ local-css loader │
│ │ │
│ ▼ │
│ .button_a1b2c { background: blue; } │
│ │
│ import styles from './button.module.css' │
│ styles.button === 'button_a1b2c' │
└─────────────────────────────────────────────────────────────┘
The two loaders
┌─────────────────────────────────────────────────────────────┐
│ css loader local-css loader │
│ │
│ .button (global) .button (scoped) │
│ :global passes through :global works │
│ No export Class map export │
└─────────────────────────────────────────────────────────────┘
The composes annotation
┌─────────────────────────────────────────────────────────────┐
│ .button { display: inline-block; } │
│ .submit { composes: button; font-weight: bold; } │
│ │
│ Element with .submit receives both classes. │
│ The order of the composed classes is undefined. │
└─────────────────────────────────────────────────────────────┘
The escape hatches
┌─────────────────────────────────────────────────────────────┐
│ local-css file: │
│ .scoped { } → scoped │
│ :global(.external) { } → global │
│ │
│ global-css file: │
│ .global { } → global │
│ :local(.scoped) { } → scoped │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Activation | .module.css suffix |
| Loader | local-css (default for .module.css) |
| Global loader | css or global-css |
| Import | import styles from './x.module.css' |
| Class map | styles.className |
composes | Inherit declarations from another class |
:global | Escape hatch to global scope |
:local | Escape hatch to local scope |
| Generated names | Implementation detail, not to hardcode |
Key takeaways:
- The
.module.csssuffix is the activation switch. A file with that suffix is processed by thelocal-cssloader, which scopes every class name and exports the mapping. A file without it is processed by thecssloader, which leaves the names global . - The import is the only stable reference to the generated name. The scoping renames the class, and the generated name changes with the minification settings. The import map is the contract; hardcoding the generated name is a bug .
- The
composesannotation composes non-overlapping declarations. It references another local class or a class from another file, and the element receives both sets of declarations. The specification leaves the order undefined, so composing classes that declare the same property is a mistake . - The
:globaland:localannotations override the loader’s default. Alocal-cssfile can mark a rule as global with:global(), and aglobal-cssfile can mark a rule as local with:local(). - The
cssloader is different from theglobal-cssloader. Thecssloader passes the annotations through unchanged, while theglobal-cssloader interprets them. Thecssloader is the default for.cssfiles, which means local CSS names are disabled by default for those files . - The loader can be configured explicitly. A project that uses a different extension for its modules can map the extension to
local-csswith theloaderoption, and a project that wants all.cssfiles to be global can useglobal-css.
Remember: CSS Modules in esbuild are a filename convention and a loader. The .module.css suffix activates the local-css loader, which scopes the names and exports the mapping. The import map is the only stable reference. The composes annotation composes non-overlapping declarations, and the :global/:local annotations override the default scope. The scoping is not a plugin and not a configuration; it is the built-in behavior of the loader, and it works in the same pass as the JavaScript bundling.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!