Skip to content

指南 ​

CSS-Zero 让你可以在契约文件(.css.ts/.css.js)中编写 CSS 样式。在构建时,编译器会执行这些文件,将每个工具调用替换为确定性的字符串令牌,并将对应的 CSS 作为单个块输出。浏览器中不会运行任何内容。

CSS-Zero 的灵感来自 vanilla-extract(类型安全的 .css.ts 编写,零运行时)和 Tailwind CSS(可组合的工具类/变体类),它们统一于一个核心理念:一切都是可复用的令牌。

为什么叫“TS中的可重用CSS”? ​

在我看来,代码的价值往往可以用其可复用性来衡量。CSS-Zero 的构建目标就是让样式易于复用,而不增加复杂性或严格的限制。

唯一的要求是在扩展名为 .css.ts/.css.js 的文件中编写样式。从此,复用成为每个层面的一等公民概念:

  • 可复用的规则 — className、classSelector、id、idSelector、variable、animation、font、layer、container 会生成小而自包含的令牌,你可以自由组合。
  • 可复用的变体 — variants 和 theme 将单个基础样式变成一组修饰符,因此一个组件可以覆盖多种视觉状态,而无需重复 CSS。
  • 可复用的条件样式 — style(config, deps) 让你可以附加样式,这些样式只有在引用的令牌确实被使用时才会在打包中生成。
  • 可复用的库 — .css.ts 模块就是一个普通模块。你可以将样式库发布到 NPM,并在任何项目中像普通依赖一样使用它,像导入其他导出一样导入令牌。该库甚至可以预构建为 .css.js — CSS-Zero 同时支持两种格式,因此使用者无需关心它是如何分发的。

每个工具返回普通字符串,或数组/扁平对象中的字符串 — 仅此而已。在构建时,编译器会将每个唯一结果替换为一个称为令牌的特殊字符串(例如 o-s_1、--o-v_1),并生成对应的 CSS。由于令牌是唯一的,未使用的样式会被 tree-shaking 移除 — 只打包你实际使用的 CSS,而且所有这些复用都不会在运行时产生任何成本。

结果:你定义一次样式,随处复用,并且只打包你使用的内容。

安装 ​

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

配置(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()],
});

在 .css.ts 文件中编写样式,并在组件中导入令牌。

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

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

工具 ​

单独规则 ​

工具签名返回
className(rule?) => string唯一的类令牌(例如 o-s_1)
classSelector(rule?) => [string, string][token, '.token']
id(rule?) => string唯一的 id 令牌
idSelector(rule?) => [string, string][token, '#token']
variable(config?) => [string, string][--token, 'var(--token)']
animation(config?) => string唯一的 @keyframes 名称令牌
font(config?) => string唯一的 @font-face 字族令牌
layer() => string唯一的 @layer token 字符串
container(type?) => [string, string][container, '@container name']

组合规则 ​

工具签名返回
variants(config, base?) => Record<string, string>修饰符令牌映射,.base.mod 选择器
theme(vars, options) => [Record, Record][varRefs, optionClasses]
style(config, deps?) => string全局样式(无 deps)或条件 AND 块

示例 ​

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]);

扩展类型 ​

通过接口声明合并来合并你自己的自定义 CSS 属性:

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

发布样式库 ​

契约模块就是普通模块,因此你可以将样式作为 NPM 包发布并使用它们:

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>

插件选项 ​

选项类型默认值描述
prefixstring'o'每个令牌的名称前缀(o-s_1、--o-v_1)。
build.inlinebooleanfalse将 CSS 内联到 <head> 中的 <style> 标签,而不是 <link>

重要 ​

在打包器之外(SSR、Jest、tsc/IDE),@css-zero/core 返回空字符串 — 签名是正确的,值是占位符。正常工作路径需要经过 @css-zero/compiler 和 @css-zero/vite-plugin。

包 ​

包作用
@css-zero/core真实的工具类型 + 用于 tsc/IDE 的字符串存根
@css-zero/compiler与构建工具无关的编译引擎
@css-zero/vite-pluginVite 门面:编译、聚合、内联/输出 CSS、移除导入

根据 Apache-2.0 许可证发布。