| | |

esbuild 13 ⚡ CSS Modules — Local scoping for component styles

⏱️ Estimated read: 9 min | |

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

ExtensionLoaderBehavior
.csscssGlobal, :local/:global passed through
.module.csslocal-cssScoped, exports class map
.css (explicit)global-cssGlobal, :local() supported

Scoping annotations

AnnotationBehavior
.class (in local-css)Scoped
:global(.class)Not scoped
:local(.class)Scoped (in global-css)

The composes annotation

SyntaxPurpose
composes: other;Compose a local class
composes: other from "file.css";Compose a class from another file

Import behavior

ImportResult
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

PitfallWhy It HappensFix
Class is undefinedFilename missing .moduleRename to *.module.css
Generated name changes on buildHardcoded the nameUse the import map
:global has no effectUsing the css loaderUse local-css or global-css
Composed property is overriddenBoth classes declare the same propertyCompose only non-overlapping declarations
Scoping not appliedLoader configured as cssCheck 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

ItemValue
Activation.module.css suffix
Loaderlocal-css (default for .module.css)
Global loadercss or global-css
Importimport styles from './x.module.css'
Class mapstyles.className
composesInherit declarations from another class
:globalEscape hatch to global scope
:localEscape hatch to local scope
Generated namesImplementation detail, not to hardcode

Key takeaways:

  • The .module.css suffix is the activation switch. A file with that suffix is processed by the local-css loader, which scopes every class name and exports the mapping. A file without it is processed by the css loader, 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 composes annotation 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 :global and :local annotations override the loader’s default. A local-css file can mark a rule as global with :global(), and a global-css file can mark a rule as local with :local() .
  • The css loader is different from the global-css loader. The css loader passes the annotations through unchanged, while the global-css loader interprets them. The css loader is the default for .css files, 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-css with the loader option, and a project that wants all .css files to be global can use global-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.

Was this helpful?


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!