A quick note on the name: there's another css-zero on 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, container produce small, composable tokens.
  • Conditionals — style(config, deps) emits styles only when the referenced tokens are actually used in the bundle.
  • Libraries — a .css.ts module 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/core in peerDependencies — at runtime the package doesn't need it, only the types.
  • Mark the package sideEffects: false so the bundler can tree-shake unused exports (and their CSS).
  • Keep @css-zero/core external 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.js extension, 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 csstype autocompletion
  • arbitrary and conditional CSS with style utility
  • work with both *.css.ts and *.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.ts files 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!