CSS Modules were designed to solve the problem of global CSS scope by creating locally scoped CSS class names.
Here’s a simple React component using CSS Modules:
// src/components/Button.module.css
.button {
background-color: blue;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
.button:hover {
background-color: darkblue;
}
// src/components/Button.jsx
import React from 'react';
import styles from './Button.module.css';
function Button({ children }) {
return (
<button className={styles.button}>
{children}
</button>
);
}
export default Button;
When this component is rendered, styles.button will resolve to a unique, generated class name like Button_button__xyz123. This prevents class name collisions with other CSS files.
The challenge with esbuild is that it’s a fast, zero-configuration bundler. By default, it doesn’t have built-in support for CSS Modules transformations. Webpack, on the other hand, has a rich plugin ecosystem that makes adding CSS Modules support straightforward with css-loader.
To achieve this with esbuild, we need to leverage its plugin API. The core idea is to intercept .module.css files, process them using a CSS Modules implementation, and then inject the generated CSS and class names into our JavaScript.
Here’s how you can set up esbuild for CSS Modules:
First, install the necessary packages:
npm install --save-dev esbuild css-modules-transform
Next, configure esbuild. The css-modules-transform package provides a plugin that handles the CSS Modules processing.
// esbuild.config.js
const esbuild = require('esbuild');
const cssModulesTransform = require('css-modules-transform');
esbuild.build({
entryPoints: ['src/index.jsx'],
bundle: true,
outfile: 'dist/bundle.js',
plugins: [
cssModulesTransform({
// Options for css-modules-transform
// You can customize the generateScopedName here if needed
// generateScopedName: '[name]__[local]___[hash:base64:5]'
}),
],
// If you're using React, you'll likely need this
loader: {
'.css': 'local-css', // Or 'css' depending on your setup and desired output
},
format: 'esm', // or 'cjs'
platform: 'browser', // or 'node'
}).catch(() => process.exit(1));
In this configuration:
entryPoints: Specifies your application’s entry file.bundle: true: Tells esbuild to bundle all your dependencies.outfile: The output JavaScript file.plugins: This is where we hook incss-modules-transform. This plugin will automatically detect and process files ending in.module.css.loader: { '.css': 'local-css' }: This is crucial.esbuildhas a built-inlocal-cssloader that, when combined with thecss-modules-transformplugin, correctly handles the CSS Modules transformation. It tells esbuild to treat.cssfiles (specifically those processed by the plugin) as local CSS, meaning the imported styles will be available as an object.
The css-modules-transform plugin, when applied to files like Button.module.css, will:
- Parse the CSS.
- Generate unique class names based on the original names and a hash (e.g.,
Button_button__xyz123). - Return a JavaScript object where keys are the original class names (e.g.,
button) and values are the generated, scoped class names (e.g.,Button_button__xyz123). - Extract the generated CSS into a separate file or inject it into the DOM. The
local-cssloader in esbuild typically handles injecting the CSS into the bundle.
The generateScopedName option in css-modules-transform allows you to customize how class names are generated, similar to how css-loader in Webpack works. The default is often sufficient, but you can tailor it for better debugging or consistency.
The most surprising thing about this setup is how seamlessly esbuild’s plugin API can integrate functionality that isn’t built-in, effectively replicating the capabilities of more complex bundlers like Webpack for specific use cases. You’re not modifying esbuild itself, but rather extending its behavior for a particular file type and transformation.
When you run node esbuild.config.js, esbuild will process your src/index.jsx and any modules it imports. If src/index.jsx imports Button.jsx, and Button.jsx imports Button.module.css via the styles object, esbuild, with the css-modules-transform plugin, will ensure that styles.button resolves to the correctly scoped class name and that the corresponding CSS is included in your final bundle.
The css-modules-transform plugin works by creating a JavaScript module that exports an object. For Button.module.css, it might produce something conceptually like this:
// This is what the plugin effectively generates for the JS module
// The actual CSS is handled by esbuild's loader for injection.
export default {
button: 'Button_button__xyz123',
// ... other classes
};
This exported object is then what import styles from './Button.module.css' resolves to in your JavaScript.
The next step is often integrating this into a larger build pipeline, perhaps for production, where you might want to extract CSS into a separate file rather than inlining it.