A CSS plugin for Rolldown that handles the full CSS pipeline in a single plugin — preprocessing, transformation, asset output, and automatic import injection.
- 🎨 Sass / SCSS / Less — auto-detects installed preprocessors, no manual configuration needed
- ⚡ LightningCSS — syntax lowering, vendor prefixing, minification, and all other LightningCSS transforms
- 📦 CSS Modules — scoped class names for
*.module.*files, exported as a JS object - 🔗 Auto import injection — automatically prepends
import './xxx.css'(orrequire) to each JS chunk that contains styles - 🗂 Per-chunk CSS output — each JS chunk gets its own CSS file, placed in a configurable subdirectory
- 🔍 Zero config — works out of the box with sensible defaults
# Required
npm add -D rolldown-plugin-css lightningcss
# Sass support (pick one, sass-embedded is faster)
npm add -D sass-embedded
# or
npm add -D sass
# Less support
npm add -D less
lightningcssis a required peer dependency. Preprocessors (sass-embedded,sass,less) are optional — only install what your project uses.
// rolldown.config.ts
import { defineConfig } from "rolldown";
import { cssRolldown } from "rolldown-plugin-css";
export default defineConfig({
input: {
index: "src/index.ts",
components: "src/components/index.ts",
},
output: {
dir: "dist",
format: "esm",
},
plugins: [cssRolldown()],
});// vite.config.ts
import { defineConfig } from "vite";
import { cssVite } from "rolldown-plugin-css";
export default defineConfig({
plugins: [cssVite()],
});dist/
css/
components.css ← styles owned by the components entry chunk
components.Dzqt_Fdc.css ← styles owned by a shared chunk
index.esm.js
components.esm.js
js/
components.Dzqt_Fdc.js ← automatically has `import '../css/components.Dzqt_Fdc.css'` prepended
The plugin options extend LightningCSS’s TransformOptions (excluding filename, code, and sourceMap which are managed internally), with two additional fields:
export interface CSSPluginOptions<C extends CustomAtRules> extends Omit<
TransformOptions<C>,
"filename" | "code" | "sourceMap"
> {
include?: number; // default: Features.Nesting | Features.CustomMediaQueries
cssDir?: string; // default: 'css'
}This means every option supported by LightningCSS transform() is available directly — targets, minify, cssModules, drafts, pseudoClasses, unusedSymbols, and more.
Type: Targets
Default: undefined
Browser targets for syntax lowering and vendor prefixing. Use browserslistToTargets from lightningcss to convert a Browserslist query.
import { browserslistToTargets } from "lightningcss";
import browserslist from "browserslist";
import { cssRolldown } from "rolldown-plugin-css";
cssRolldown({
targets: browserslistToTargets(browserslist(">= 0.5%, not dead")),
});Type: number (LightningCSS Features bitmask)
Default: Features.Nesting | Features.CustomMediaQueries
Controls which CSS draft features LightningCSS should transform/lower. Features is re-exported from this plugin for convenience.
import { cssRolldown, Features } from "rolldown-plugin-css";
cssRolldown({
// Lower CSS Nesting and Custom Media Queries (default)
include: Features.Nesting | Features.CustomMediaQueries,
});See the LightningCSS Features documentation for all available flags.
Type: boolean
Default: false
Minify the CSS output using LightningCSS.
import { cssRolldown } from "rolldown-plugin-css";
cssRolldown({
minify: process.env.NODE_ENV === "production",
});Type: CSSModulesConfig | boolean
Default: undefined
LightningCSS CSS Modules configuration. When set, applies to all CSS Module files (*.module.*). The plugin detects CSS Module files by filename pattern — you don’t need to enable this manually for the detection to work, but you can use this option to customize the generated class name pattern and other CSS Modules behavior.
import { cssRolldown } from "rolldown-plugin-css";
cssRolldown({
cssModules: {
pattern: "[hash]_[local]", // default scoped class name pattern
},
});See the LightningCSS CSS Modules documentation for all available options.
Type: string
Default: 'css'
The subdirectory (relative to output.dir) where CSS asset files are written. The injected import path is automatically computed relative to each JS chunk’s location.
import { cssRolldown } from "rolldown-plugin-css";
cssRolldown({ cssDir: "css" }); // → dist/css/components.css (default)
cssRolldown({ cssDir: "assets/styles" }); // → dist/assets/styles/components.css
cssRolldown({ cssDir: "" }); // → dist/components.cssAny file matching *.module.* is treated as a CSS Module. The plugin extracts scoped class names and exports them as a plain JS object.
/* Button.module.scss */
.button {
background: royalblue;
color: white;
&:hover {
background: darkblue; /* CSS Nesting — lowered by LightningCSS */
}
}// Button.tsx
import styles from "./Button.module.scss";
export function Button({ label }: { label: string }) {
return <button className={styles.button}>{label}</button>;
}The compiled output exports a class name map:
// compiled output (simplified)
const classes = { button: "a1b2c_button" };
export default classes;The corresponding CSS (.a1b2c_button { ... }) is extracted and written to the chunk’s CSS asset file.
Because the plugin options extend TransformOptions directly, you have access to all LightningCSS features:
import { cssRolldown, Features } from "rolldown-plugin-css";
import { browserslistToTargets } from "lightningcss";
import browserslist from "browserslist";
cssRolldown({
// Browser targets
targets: browserslistToTargets(browserslist(">= 0.5%, not dead")),
// Features to lower
include:
Features.Nesting |
Features.CustomMediaQueries |
Features.MediaRangeSyntax |
Features.OklabColors,
// Minify in production
minify: process.env.NODE_ENV === "production",
// Custom CSS Modules class name pattern
cssModules: {
pattern: "[name]__[local]--[hash]",
},
// CSS output directory
cssDir: "assets",
});The plugin automatically reads output.format from Rolldown’s output options and injects import or require accordingly. No extra configuration needed.
import { cssRolldown } from "rolldown-plugin-css";
export default defineConfig({
input: "src/index.ts",
output: [
{ dir: "dist/esm", format: "esm" }, // → import './css/index.css'
{ dir: "dist/cjs", format: "cjs" }, // → require('./css/index.css')
],
plugins: [cssRolldown()],
});If you want CSS files to be emitted without injecting import statements into JS (e.g. for a component library where consumers control style loading), you can fork the plugin or disable injection via a future option. For now, the recommended approach is to use the plugin as-is and document that consumers should import the CSS separately.
The plugin detects which preprocessor to use based on file extension:
.scss/.sass→ triessass-embeddedfirst (native binary, ~10× faster), falls back tosass.less→ usesless.css→ skips preprocessing, goes directly to LightningCSS
The loaded module is cached at the module level, so the dynamic import() only runs once per build process.
Rolldown only understands JavaScript modules. The transform hook converts each CSS file into a JS module that Rolldown can include in the module graph:
- Preprocessor (Sass/Less): compiles to plain CSS, captures the source map
- LightningCSS: transforms the CSS (nesting, vendor prefix, syntax lowering, etc.), with the preprocessor source map passed as
inputSourceMapso the final source map traces back to the original.scss/.lessfile - CSS Module files (
*.module.*): returns a JS module exporting the scoped class name map —export default { button: 'a1b2c_button' } - Plain CSS files: returns an empty JS comment —
/* css-plugin: path/to/file.css */— as a placeholder to keep the module in the graph
Both return moduleSideEffects: true to prevent tree-shaking from removing the module before generateBundle can see it.
Once all chunks are sealed, the plugin iterates over every chunk and:
- Finds CSS module IDs owned by this chunk via
Object.keys(chunk.modules)— this includes placeholder modules thatchunk.moduleIds(the tree-shaken list) might omit - Concatenates the CSS strings in import order
- Emits a CSS asset file via
this.emitFile - Prepends an
import './xxx.css'(orrequire) statement tochunk.code, using a path relative to the JS chunk’s output location
The inject step happens in the same loop iteration where cssFileName is already known — no separate plugin, no naming convention guesswork, no shared state.
| Package | Required | Notes |
|---|---|---|
rolldown |
✅ | ^1.0.0-rc.8 or later |
lightningcss |
✅ | Any recent version |
sass-embedded |
optional | For .scss/.sass (recommended) |
sass |
optional | For .scss/.sass (fallback) |
less |
optional | For .less |
MIT