DEV Community

Cover image for CSS-Zero: Reuse-sharpened CSS-in-TS with Zero Runtime
Marat Sabitov
Marat Sabitov

Posted on

CSS-Zero: Reuse-sharpened CSS-in-TS with Zero Runtime

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
Enter fullscreen mode Exit fullscreen mode
// 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()],
});
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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' } }
);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
import { btn, btnBg } from 'your-lib/src/buttons.css';

<button className={`${btn} ${btnBg.primary}`}>Button</button>
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (1)

Collapse
 
webdeveloperhyper profile image
Web Developer Hyper •

Wow! Zero runtime is a great feature. The faster, the better. Cool new app! 😄