Skip to content

Guide ​

CSS-Zero lets you write CSS styles in contract files (.css.ts/.css.js). At build time the compiler executes those files, replaces every utility call with a deterministic string token, and emits the matching CSS as a single chunk. Nothing runs in the browser.

CSS-Zero is inspired by vanilla-extract (type-safe .css.ts authoring with zero runtime) and Tailwind CSS (composable utility/variant classes), unified around a single idea: everything is a reusable token.

Why is it reuse-sharpened? ​

In my opinion, the value of code can often be measured by its reusability. CSS-Zero is built around making styles reusable without complexity or strict limitation.

The only requirement is to write styles in files with the .css.ts/.css.js extension. From there, reuse is a first-class concept at every level:

  • Reusable rules — className, classSelector, id, idSelector, variable, animation, font, layer, container produce small, self-contained tokens you can compose freely.
  • Reusable variants — variants and theme turn a single base style into a family of modifiers, so one component can cover many visual states without duplicating CSS.
  • Reusable conditionals — style(config, deps) lets you attach styles that are emitted only when the referenced tokens are actually used in the bundle.
  • Reusable libraries — a .css.ts module is just a module. You can publish a styles library to NPM and consume it as a normal dependency in any project, importing tokens like any other export. The library can even be pre-built to .css.js — CSS-Zero understands both, so consumers never care how it was shipped.

Every utility returns a plain string or strings inside arrays / flat objects — nothing more. At build time the compiler substitutes each unique result with a special string called a token (e.g. o-s_1, --o-v_1) and emits the matching CSS. Because tokens are unique, unused styles are tree-shaken away — only the CSS you actually use ships, and none of this reuse costs anything at runtime.

As a result you define style once, reuse everywhere, and ship only what you use.

Install ​

bash
npm i @css-zero/core @css-zero/vite-plugin

Setup (Vite) ​

ts
// 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 file and import the tokens in your components.

tsx
// Button.tsx
import { btn } from './styles.css.ts';

export function Button() {
    return <button className={btn}>Click</button>;
}

Utilities ​

Individual rules ​

UtilitySignatureReturns
className(rule?) => stringUnique class token (e.g. o-s_1)
classSelector(rule?) => [string, string][token, '.token']
id(rule?) => stringUnique id token
idSelector(rule?) => [string, string][token, '#token']
variable(config?) => [string, string][--token, 'var(--token)']
animation(config?) => stringUnique @keyframes name token
font(config?) => stringUnique @font-face family token
layer() => stringUnique @layer token string
container(type?) => [string, string][container, '@container name']

Composed rules ​

UtilitySignatureReturns
variants(config, base?) => Record<string, string>Modifier token map, .base.mod selector
theme(vars, options) => [Record, Record][varRefs, optionClasses]
style(config, deps?) => stringGlobal styles (no deps) or conditional AND chunk

Examples ​

className ​

ts
import { className } from '@css-zero/core';

export const btn = className({
    color: 'white',
    '&:hover': { color: 'gray' },
});
// → 'o-s_1'

classSelector ​

ts
import { classSelector } from '@css-zero/core';

export const [card, cardSelector] = classSelector({
    display: 'block',
    padding: '16px',
    borderRadius: '8px',
});
// card → 'o-s_1', cardSelector → '.o-s_1'

id ​

ts
import { id } from '@css-zero/core';

export const badge = id({ backgroundColor: 'red' });
// → 'o-i_1'

idSelector ​

ts
import { idSelector } from '@css-zero/core';

export const [modal, modalSelector] = idSelector({
    position: 'fixed',
    inset: 0,
});
// modal → 'o-i_1', modalSelector → '#o-i_1'

variable ​

ts
import { variable } from '@css-zero/core';

// [name, var(name)]
export const [accentKey, accent] = variable('#2b6cb0');
// → ['--o-v_1', 'var(--o-v_1)']

animation ​

ts
import { animation } from '@css-zero/core';

export const spin = animation({
    from: { transform: 'rotate(0)' },
    to: { transform: 'rotate(360deg)' },
});

font ​

ts
import { font } from '@css-zero/core';

export const inter = font({
    src: "url('/fonts/inter.woff2') format('woff2')",
    weight: '400 700',
    display: 'swap',
});
// → 'o-f_1' — use it as `fontFamily: Inter`

layer ​

ts
import { layer } from '@css-zero/core';

export const base = layer();
// → '@layer o-l_1'

container ​

ts
import { container } from '@css-zero/core';

export const [card, cardQuery] = container('inline-size');
// card → 'o-c_1 / inline-size', cardQuery → '@container o-c_1'

variants ​

ts
import { variants } from '@css-zero/core';

export const tone = variants(
    { primary: { color: '#fff' }, ghost: { color: 'gray' } },
    btn // optional base token → selector becomes `.btn.primary`
);
// → { primary: 'o-s_2', ghost: 'o-s_3' }

theme ​

ts
import { theme } from '@css-zero/core';

// [varRefs, optionClasses]
export const [tokens, options] = theme(
    { accent: '#2b6cb0', spacing: '8px' },
    { dark: { accent: '#0f0' }, compact: { spacing: '4px' } }
);
// tokens.accent → 'var(--o-v_1)'
// options.dark  → 'o-s_1' (overrides accent when applied)

style ​

ts
import { style } from '@css-zero/core';

// no deps - always emitted
style({ body: { margin: 0 } });

// emitted only if all deps are used in the bundle
style({ [`${btnSelector} > span`]: { display: 'block' } }, [btnSelector]);

Extending types ​

Merge your own custom CSS properties via interface declaration merging:

ts
declare global {
    namespace CSSZero {
        interface ExtraProperties {
            '--brand': string;
        }
    }
}

Publishing a styles library ​

Contract modules are ordinary modules, so you can publish styles as an NPM package and consume them:

ts
// your-lib/src/buttons.css.ts
export const [btn, btnSelector] = classSelector({ /* ... */ });
export const btnBg = variants({ primary: { /* ... */ }, ghost: { /* ... */ } }, btn);
tsx
import { btn, btnBg } from 'your-lib/src/buttons.css';

<button className={`${btn} ${btnBg.primary}`}>Button</button>

Plugin options ​

OptionTypeDefaultDescription
prefixstring'o'Name prefix for every token (o-s_1, --o-v_1).
build.inlinebooleanfalseInline CSS into a <style> tag in <head> instead of a <link>

Important ​

Outside a bundler (SSR, Jest, tsc/IDE), @css-zero/core returns empty strings — the signatures are correct, the values are placeholders. The working path goes through @css-zero/compiler and @css-zero/vite-plugin.

Packages ​

PackageRole
@css-zero/coreReal utility types + string stubs for tsc/IDE
@css-zero/compilerBuild-tool-agnostic compile engine
@css-zero/vite-pluginVite facade: compiles, aggregates, inlines/emits CSS, strips imports

Released under the Apache-2.0 License.