| | |

esbuild 12 ⚡ CSS Bundling — Native CSS imports & @import resolution

⏱️ Estimated read: 10 min | |

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

ExtensionLoaderBehavior
.csscssStandard CSS
.module.csslocal-cssScoped class names
.png, .svgdataurl or fileInline or copy
.woff2dataurl or fileInline or copy

Bundling commands

CommandEffect
esbuild --bundle app.css --outfile=out.cssCSS as entry point
import './app.css'CSS from JavaScript
loader: { '.png': 'dataurl' }Inline images
loader: { '.woff2': 'file' }Copy fonts

Transformations

FeatureTransformed when
Nested declarationsTarget lacks support
lab() / lch()Target lacks support
oklab() / oklch()Target lacks support
color()Target lacks support
Gradient transition hintsTarget lacks support

CSS Modules

ItemDetail
File pattern*.module.css
Loaderlocal-css
Importimport styles from './file.module.css'
Class mapstyles.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

PitfallWhy It HappensFix
url() is not rewrittenNo loader configured for the file typeAdd loader: { '.png': 'file' }
Image is not inlinedNo dataurl loaderAdd loader: { '.png': 'dataurl' }
CSS Module class is undefinedFilename missing .moduleRename to *.module.css
Modern syntax fails in old browserNo target setAdd --target=chrome80
@import is not inlinedPath is externalCheck 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

ItemValue
CSS loadercss (default for .css)
CSS Modules loaderlocal-css (default for .module.css)
Entry pointCSS file can be the entry
@import behaviorResolved and inlined recursively
url() behaviorRequires a loader (dataurl or file)
Image loaderdataurl (inline) or file (copy)
Font loaderdataurl or file
Syntax transformationApplied based on target
CSS Modules importReturns the class-name map

Key takeaways:

  • CSS is a first-class content type in esbuild. A .css file can be the entry point, and the bundler resolves the @import rules and inlines the imported stylesheets without requiring a JavaScript file to import the CSS .
  • The @import resolution is recursive. An imported CSS file can contain its own @import rules, 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. 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 .
  • CSS Modules are enabled by the filename convention. A *.module.css file uses the local-css loader, 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 target option determines which features are downleveled. The transformations include nested declarations, lab() and oklab(), 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.

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!