ecij (Extract CSS-in-JS) is a zero-runtime CSS-in-JS plugin for Vite, tsdown, and Rolldown.
It achieves this via static analysis by using oxc-parser, so it is limited to static expressions. The plugin will ignore dynamic or complex expressions.
The plugin does not process the CSS in any way whatsoever. It is merely output in virtual CSS files for the bundler to handle. Separate plugins may be used to process these virtual CSS files.
npm install -D ecijThe plugin is built on Rolldown's APIs, so rolldown is a required peer dependency. In Vite and tsdown projects it is already part of the dependency tree — npm and pnpm resolve the peer to that same copy — but package managers that do not install peer dependencies automatically, such as Yarn, require it to be installed explicitly.
Source input:
/* main.ts */
import { css } from 'ecij';
import { redClassname } from './styles';
const myButtonClassname = css`
border: 1px solid blue;
&.${redClassname} {
border-color: red;
}
`;/* styles.ts */
import { css } from 'ecij';
const color = 'red';
export const redClassname = css`
color: ${color};
`;Build output:
/* js */
const color = 'red';
const redClassname = 'css-a1b2c3d4';
const myButtonClassname = 'css-1d2c3b4a';/* css */
.css-a1b2c3d4 {
color: red;
}
.css-1d2c3b4a {
border: 1px solid blue;
&.css-a1b2c3d4 {
border-color: red;
}
}Add the plugin to vite.config.ts, tsdown.config.ts, or rolldown.config.ts:
import { ecij } from 'ecij/plugin';
import { defineConfig } from 'vite'; // or 'tsdown' or 'rolldown'
export default defineConfig({
// ...
plugins: [ecij()],
});tsdown also needs @tsdown/css to be installed to bundle the extracted CSS.
The ecij() plugin accepts an optional configuration object:
export interface Configuration {
/**
* Include patterns for files to process, matched against the module id.
*
* @default `/\.[cm]?[jt]sx?$/`
*/
include?: string | RegExp | ReadonlyArray<string | RegExp> | undefined | null;
/**
* Exclude patterns for files to skip, matched against the module id.
*
* @default `[/\/node_modules\//, /\.d\.ts$/]`
*/
exclude?: string | RegExp | ReadonlyArray<string | RegExp> | undefined | null;
/**
* Prefix for generated CSS class names.
*
* Should not be empty, as generated hashes may start with a digit, resulting in invalid CSS
* class names.
*
* @default `'css-'`
*/
classPrefix?: string | undefined | null;
}Example:
ecij({
classPrefix: 'lib-',
});Interpolations are resolved at build time, following lexical scoping:
import { css } from 'ecij';
import { iconClassname } from './icon';
import { spacing } from './tokens';
const brand = 'blue';
const accent = brand;
export const buttonClassname = css`
margin: ${-1}px ${8}px; /* string and number literals, including signed numbers */
color: ${accent}; /* const bindings of such literals or of other bindings that resolve */
padding: ${spacing}px; /* named imports of such const bindings from other modules */
/* generated class names, including imported ones */
&:hover .${iconClassname} {
fill: white;
}
`;let and var bindings, parameters, and destructured bindings are never resolved, but they still shadow outer bindings of the same name.
Any other interpolation, like a call, a ternary, a template literal, or an identifier that does not resolve, causes the whole css`` block to be skipped with a warning (COMPLEX_INTERPOLATION or UNRESOLVED_INTERPOLATION). The runtime css call is left in place, so the mistake fails loudly instead of silently shipping broken styles.
The css tag must be a named import from 'ecij', which may be aliased, like import { css as styled } from 'ecij'. A local binding of the same name shadows it, so its templates are left alone.
# Build the plugin
npm run build
# Format the code
npm run format
# Run the linter
npm run lint
# Auto-fix lints
npm run lint:fix
# Type check
npm run typecheck
# Run tests once
npm test
# Update test snapshots
npm test -- -u- Full import/export handling (default/namespace import/export)