A quick note on the name: there's another
css-zeroon npm — a tool released in 2019, not mine. Mine is@css-zero/core, it's scoped ecosystem. So we are different. Catchy names are always in high demand.
I've been building EffCSS, a runtime CSS-in-TS solution, for a while — but generating styles in the browser isn't always ideal. The zero-runtime approach, where CSS is produced at build time, has long been in vogue, and I wanted to try it.
Two tools inspired me: vanilla-extract and Tailwind. I love the .css.ts extension for defining styles, and I prefer generating styles from tokens present in the build over analyzing imports/exports in the AST. I also kept the API compact by reusing utility naming conventions from EffCSS.
Combining these ideas gave me CSS-Zero — reuse-sharpened CSS-in-TS with zero runtime.
Quick start
npm i @css-zero/core @css-zero/vite-plugin
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { cssZero } from '@css-zero/vite-plugin';
export default defineConfig({
plugins: [react(), cssZero()],
});
Write styles in a .css.ts contract module and import the tokens in your components — the plugin compiles them into a single css-zero.css asset and tree-shakes it against the final JS.
Why is it reuse-sharpened?
In my opinion, the value of code can often be measured by its reusability. CSS-Zero makes styles reusable without complexity or strict limitation — the only requirement is writing them in .css.ts/.css.js files. Reuse is first-class at every level:
-
Rules —
className,classSelector,id,idSelector,variable,animation,font,layer,containerproduce small, composable tokens. -
Conditionals —
style(config, deps)emits styles only when the referenced tokens are actually used in the bundle. -
Libraries — a
.css.tsmodule is just a module: publish it to NPM and consume it as a normal dependency. It can even be pre-built to.css.js— CSS-Zero understands both.
Every utility returns a plain string or strings inside arrays / flat objects — nothing more. Define once, reuse everywhere, ship only what you use.
How it works
The plugin executes the graph of contract modules (*.css.ts / *.css.js) at build time, substituting each unique result with a token and emitting the matching CSS. Because tokens are unique, unused styles are tree-shaken away — only the CSS you actually use ships.
This is where CSS-Zero differs from its inspirations. vanilla-extract analyzes imports/exports in the AST; Tailwind scans for utility classnames. CSS-Zero keys everything off the tokens present in the final bundle — a more direct signal of what's used, identical whether the styles come from your files or a published library.
Speaking of the compact API idea, the library contains just 12 utilities, 9 of which create individual rules
| Utility | Signature | Returns |
|---|---|---|
className |
(rule?) => string |
Unique class token (e.g. o-s_1) |
classSelector |
(rule?) => [string, string] |
[token, '.token'] |
id |
(rule?) => string |
Unique id token |
idSelector |
(rule?) => [string, string] |
[token, '#token'] |
variable |
(config?) => [string, string] |
[--token, 'var(--token)'] |
animation |
(config?) => string |
Unique @keyframes name token |
font |
(config?) => string |
Unique @font-face family token |
layer |
() => string |
Unique cascade-layer selector @layer token
|
container |
(type?) => [string, string] |
[name + type, '@container name'] (e.g. ['o-c_1 / inline-size', '@container o-c_1']) |
The 3 remaining utilities create composite styles
| Utility | Signature | Returns |
|---|---|---|
variants |
(config, base?) => Record<string, string> |
Modifier token map, .base.mod selector |
theme |
(vars, options) => [Record, Record] |
[varRefs, optionClasses] |
style |
(config, deps?) => string |
Global styles (no deps) or conditional AND chunk |
When defining styles, you can use standard CSS nesting; there are no special keys or restrictions:
import { className } from '@css-zero/core';
export const btn = className({
color: 'white',
'&:hover': { color: 'gray' },
'& > span': { paddingLeft: '0.25rem', paddingRight: '0.25rem' }
});
// → 'o-s_1'
The composite utilities turn reuse into a one-liner. variants builds a modifier map on top of a base token, and theme splits design tokens into variable references and option classes:
import { variants, theme } from '@css-zero/core';
// Modifier map: { primary: 'o-v_1', ghost: 'o-v_2' }
// If btn -> `o-s_1` then
// selectors emitted: `.o-s_1.o-s_2`, `.o-s_1.o-s_3`
export const tone = variants({ primary: { color: '#fff' }, ghost: { color: 'gray' } }, btn);
// Theme: [varRefs, optionClasses]
// tokens.accent → 'var(--o-v_1)'
// options.dark → 'o-s_2' (overrides accent when applied)
export const [tokens, options] = theme(
{ accent: '#2b6cb0', spacing: '8px' },
{ dark: { accent: '#0f0' }, compact: { spacing: '4px' } }
);
The style utility also accepts an array of dependencies as its second argument, giving you control over what ships:
import { style } from '@css-zero/core';
import { cardSelector, titleSelector } from 'another.css.ts';
// no deps - always emitted
style({ body: { margin: 0 } });
// emitted only if all deps are used in the bundle
style({ [`${cardSelector} > ${titleSelector}`]: { margin: '0 auto' } }, [cardSelector, titleSelector]);
If cardSelector or titleSelector aren't used, those styles are useless — so the compiler only adds them when all deps are present. I guess I've been writing too much React :)
Publishing a styles library
As I mentioned earlier, the style library can be published as a standalone npm package and imported like a standard dependency:
// your-lib/src/buttons.css.ts
export const [btn, btnSelector] = classSelector({ /* ... */ });
export const btnBg = variants({ primary: { /* ... */ }, ghost: { /* ... */ } }, btn);
import { btn, btnBg } from 'your-lib/src/buttons.css';
<button className={`${btn} ${btnBg.primary}`}>Button</button>
There are several practical rules to make a styles package work smoothly:
- Keep
@css-zero/coreinpeerDependencies— at runtime the package doesn't need it, only the types. - Mark the package
sideEffects: falseso the bundler can tree-shake unused exports (and their CSS). - Keep
@css-zero/coreexternal in the build — tokens are resolved by the consumer's compiler. - You can even minify the bundle if you wish—the only important thing is to keep the
*.css.jsextension, but don't forget to export all produced results to prevent them be eliminated from minified code.
Key advantages
- compact API
- precise token-based CSS filtering
- native CSS nesting with
csstypeautocompletion - arbitrary and conditional CSS with
styleutility - work with both
*.css.tsand*.css.js
When to Consider Alternatives
- you want to get CSS in separate files attached to the TS/JS source files
- you need runtime CSS generation
- you are already using
.css.tsfiles with another library, such as vanilla-extract - you need a multi-page app (MPA) in a single build — CSS-Zero is built around one entrypoint per build (each page as its own SPA build)
Final thoughts
CSS-Zero is my attempt to make styles genuinely reusable without a runtime cost or a restrictive API. Keep the API tiny, key everything off tokens, and let the compiler ship only what you use. If that resonates, give it a try — may be it brings you new developer experience.
Enjoy your Frontend Development!
Top comments (1)
Wow! Zero runtime is a great feature. The faster, the better. Cool new app! 😄