esbuild 12 ⚡ CSS Bundling — Native CSS imports & @import resolution
esbuild treats CSS as a first-class content type. It can bundle a .css file directly as an entry point, without requiring a JavaScript file to import it . When a CSS file is bundled, esbuild resolves its @import rules, inlines the imported stylesheets, and rewrites the url() references to the correct output paths. The css loader is enabled by default for .css files, and the local-css loader is enabled by default for .module.css files .
This chapter covers the native CSS bundling behavior: the @import inlining, the url() rewriting and the loader configuration it requires, the CSS Modules support, and the syntax transformations that esbuild applies to modern CSS features.
Key point: esbuild bundles CSS natively. A .css file can be an entry point on its own, and @import rules are resolved and inlined recursively. The url() references to images and fonts are not inlined by default; they require a loader configuration (such as dataurl or file) to be processed .
Why CSS is a first-class content type
The entry-point problem. In many bundlers, a CSS file is only processed if a JavaScript file imports it. esbuild allows the CSS file to be the entry point: esbuild --bundle app.css --outfile=out.css . The bundler reads the CSS, resolves the @import rules, and writes a single output stylesheet. This is the mechanism for a project that has a stylesheet that is not imported from any JavaScript module.
The @import problem. A CSS file can contain @import "other.css". When esbuild bundles the file, it resolves the import path, reads the imported file, and replaces the @import rule with the imported content. The process is recursive: the imported file can contain its own @import rules, and those are resolved as well. The result is a single stylesheet with every imported rule inlined .
The url() problem. A CSS file can contain url("./image.png") or url("./font.woff2"). esbuild does not have a pre-configured loader for these file types. To process them, the build must configure a loader for the corresponding extension. The dataurl loader inlines the file as a base64 data URL, and the file loader copies the file to the output directory and rewrites the URL to the new path .
a. The @import inlining and the entry point
A CSS file can be the entry point, or it can be imported from a JavaScript file. In both cases, the @import rules are resolved and inlined.
# CSS as the entry point
esbuild --bundle app.css --outfile=out.css
// CSS imported from JavaScript
import './app.css'
The app.css file can contain @import rules:
/* app.css */
@import './reset.css';
@import './layout.css';
.button {
background: blue;
}
The output out.css contains the rules from reset.css and layout.css, followed by the .button rule. The @import statements are not preserved in the output; they are replaced by the imported content .
The resolution of the @import path follows the same rules as the JavaScript module resolution. A relative path (./reset.css) is resolved relative to the importing file. A package path (@import 'normalize.css') is resolved from node_modules.
b. The url() rewriting and the loader configuration
The url() references in a CSS file are not processed unless the build configures a loader for the file type. The dataurl loader inlines the file as a base64 data URL, and the file loader copies the file to the output and rewrites the URL.
// esbuild.build.js
await esbuild.build({
entryPoints: ['app.css'],
bundle: true,
loader: {
'.png': 'dataurl',
'.woff2': 'file',
},
outfile: 'out.css',
})
With the dataurl loader, url('./icon.png') becomes a data:image/png;base64,... URL in the output. The image is embedded in the stylesheet, which eliminates the separate network request but increases the stylesheet size. With the file loader, the image is copied to the output directory, and the URL is rewritten to the new path .
The choice between the two loaders is a trade-off between the number of requests and the size of the stylesheet. A small icon is a candidate for dataurl; a large background image or a font file is a candidate for file.
c. The CSS Modules and the syntax transformation
A file named *.module.css uses the local-css loader by default. The class names are scoped to the module, and the JavaScript import receives a map from the original class names to the generated ones .
/* button.module.css */
.button {
background: blue;
}
import styles from './button.module.css'
// styles.button is the generated class name
esbuild also transforms modern CSS syntax for older browsers, depending on the configured target. The transformations include nested declarations, modern RGB/HSL syntax, the inset shorthand, hwb(), lab() and lch(), oklab() and oklch(), color(), color stops with two positions, and gradient transition hints .
# Target an older browser
esbuild app.css --bundle --target=chrome80 --outfile=out.css
The transformation is applied when the target does not support the syntax natively. A lab() color is converted to a fallback in the legacy syntax, and a nested declaration is flattened. The gradient() function and the color interpolation syntax are also transformed, as the gradient test page demonstrates .
Complete Example Session
/* ============================================
PART 1: THE CSS ENTRY POINT
============================================ */
/* app.css */
@import './reset.css';
.button {
background: url('./icon.png');
}
# ============================================
# PART 2: THE BASIC BUNDLE
# ============================================
esbuild --bundle app.css --outfile=out.css
# The @import is inlined.
# The url() is left as-is unless a loader is configured.
// ============================================
// PART 3: THE LOADER CONFIGURATION
// ============================================
await esbuild.build({
entryPoints: ['app.css'],
bundle: true,
loader: {
'.png': 'dataurl',
},
outfile: 'out.css',
})
/* ============================================
PART 4: THE OUTPUT WITH DATAURL
============================================ */
/* The icon is inlined as a data URL. */
.button {
background: url('data:image/png;base64,...');
}
// ============================================
// PART 5: THE FILE LOADER
// ============================================
await esbuild.build({
entryPoints: ['app.css'],
bundle: true,
loader: {
'.woff2': 'file',
},
outfile: 'out.css',
})
/* ============================================
PART 6: THE OUTPUT WITH FILE LOADER
============================================ */
/* The font is copied to the output directory. */
@font-face {
src: url('./font.woff2');
}
/* ============================================
PART 7: THE CSS MODULE
============================================ */
/* button.module.css */
.button {
background: blue;
}
// ============================================
// PART 8: THE CSS MODULE IMPORT
// ============================================
import styles from './button.module.css'
// styles.button is scoped.
# ============================================
# PART 9: THE TARGET TRANSFORMATION
# ============================================
esbuild app.css --bundle --target=chrome80 --outfile=out.css
# Modern syntax is downleveled for Chrome 80.
# ============================================
# PART 10: THE JAVASCRIPT IMPORT
# ============================================
# app.js
import './app.css'
# The CSS is bundled alongside the JavaScript.
The ten parts covered the CSS entry point, the basic bundle, the loader configuration, the dataurl output, the file loader, the file loader output, the CSS module, the module import, the target transformation, and the JavaScript import.
Quick Reference
CSS loaders
| Extension | Loader | Behavior |
|---|---|---|
.css | css | Standard CSS |
.module.css | local-css | Scoped class names |
.png, .svg | dataurl or file | Inline or copy |
.woff2 | dataurl or file | Inline or copy |
Bundling commands
| Command | Effect |
|---|---|
esbuild --bundle app.css --outfile=out.css | CSS as entry point |
import './app.css' | CSS from JavaScript |
loader: { '.png': 'dataurl' } | Inline images |
loader: { '.woff2': 'file' } | Copy fonts |
Transformations
| Feature | Transformed when |
|---|---|
| Nested declarations | Target lacks support |
lab() / lch() | Target lacks support |
oklab() / oklch() | Target lacks support |
color() | Target lacks support |
| Gradient transition hints | Target lacks support |
CSS Modules
| Item | Detail |
|---|---|
| File pattern | *.module.css |
| Loader | local-css |
| Import | import styles from './file.module.css' |
| Class map | styles.className |
Best Practices
✅ Do This:
# Use CSS as the entry point when it is not imported from JS
esbuild --bundle app.css --outfile=out.css # ✅
// Configure a loader for images and fonts
loader: { '.png': 'dataurl', '.woff2': 'file' } // ✅
# Use the target option for older browsers
esbuild app.css --bundle --target=chrome80 # ✅
/* Use .module.css for scoped styles */
/* button.module.css */ // ✅
❌ Don’t Do This:
# Assume url() is processed without a loader
esbuild --bundle app.css --outfile=out.css # ❌ (url left as-is)
# Inline large fonts with dataurl
loader: { '.woff2': 'dataurl' } // ❌ (bloats CSS)
# Use a .css file for scoped styles
/* button.css */ // not scoped // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
url() is not rewritten | No loader configured for the file type | Add loader: { '.png': 'file' } |
| Image is not inlined | No dataurl loader | Add loader: { '.png': 'dataurl' } |
| CSS Module class is undefined | Filename missing .module | Rename to *.module.css |
| Modern syntax fails in old browser | No target set | Add --target=chrome80 |
@import is not inlined | Path is external | Check the path and the resolution |
Real-World Examples
1. CSS entry point
esbuild --bundle app.css --outfile=out.css
2. CSS from JavaScript
import './app.css'
3. dataurl loader
loader: { '.png': 'dataurl' }
4. file loader
loader: { '.woff2': 'file' }
5. CSS module
import styles from './button.module.css'
6. Target transformation
esbuild app.css --bundle --target=chrome80
7. The @import
@import './reset.css';
8. The url()
background: url('./icon.png');
9. The output
background: url('data:image/png;base64,...');
10. The module output
._button_a1b2c { background: blue; }
Visual
The CSS bundling pipeline
┌─────────────────────────────────────────────────────────────┐
│ app.css │
│ ├── @import './reset.css' ──► inlined │
│ ├── @import './layout.css' ──► inlined │
│ └── .button { url('./icon.png') } │
│ │
│ Loader for .png: │
│ ├── dataurl ──► base64 inline │
│ └── file ──► copied to output, URL rewritten │
└─────────────────────────────────────────────────────────────┘
The CSS Modules
┌─────────────────────────────────────────────────────────────┐
│ button.module.css │
│ .button { background: blue; } │
│ │ │
│ ▼ │
│ local-css loader │
│ │ │
│ ▼ │
│ ._button_a1b2c { background: blue; } │
│ │
│ import styles from './button.module.css' │
│ styles.button === '_button_a1b2c' │
└─────────────────────────────────────────────────────────────┘
The target transformation
┌─────────────────────────────────────────────────────────────┐
│ Source: color: lab(50% 40 59.5); │
│ │ │
│ ▼ │
│ Target chrome80 (no lab() support) │
│ │ │
│ ▼ │
│ Output: color: #e54b4b; │
└─────────────────────────────────────────────────────────────┘
The entry-point options
┌─────────────────────────────────────────────────────────────┐
│ CSS as entry point: │
│ esbuild --bundle app.css --outfile=out.css │
│ │
│ CSS from JavaScript: │
│ import './app.css' │
│ │
│ Both produce a bundled stylesheet. │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| CSS loader | css (default for .css) |
| CSS Modules loader | local-css (default for .module.css) |
| Entry point | CSS file can be the entry |
@import behavior | Resolved and inlined recursively |
url() behavior | Requires a loader (dataurl or file) |
| Image loader | dataurl (inline) or file (copy) |
| Font loader | dataurl or file |
| Syntax transformation | Applied based on target |
| CSS Modules import | Returns the class-name map |
Key takeaways:
- CSS is a first-class content type in esbuild. A
.cssfile can be the entry point, and the bundler resolves the@importrules and inlines the imported stylesheets without requiring a JavaScript file to import the CSS . - The
@importresolution is recursive. An imported CSS file can contain its own@importrules, and esbuild resolves them all. The output is a single stylesheet with every imported rule inlined. - The
url()references require a loader. esbuild does not have a pre-configured loader for images and fonts. Thedataurlloader inlines the file as a base64 data URL, and thefileloader copies the file to the output and rewrites the URL . - CSS Modules are enabled by the filename convention. A
*.module.cssfile uses thelocal-cssloader, and the JavaScript import receives a map from the original class names to the generated ones . - Modern CSS syntax is transformed for older browsers. The
targetoption determines which features are downleveled. The transformations include nested declarations,lab()andoklab(),color(), and gradient transition hints . - The CSS entry point and the JavaScript import produce the same output. The choice is about the project structure: a stylesheet that is not imported from any JavaScript module can be the entry point, and a stylesheet that is imported from a module is bundled through the JavaScript entry.
Remember: esbuild bundles CSS natively. The @import rules are resolved and inlined, the url() references are processed with the configured loader, and the CSS Modules are scoped by the filename convention. The target option controls the syntax transformation. Use the dataurl loader for small images and the file loader for large files and fonts. And remember that the CSS file can be the entry point, which makes esbuild a complete CSS bundler without a JavaScript intermediate.
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!