指南
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>插件选项
| 选项 | 类型 | 默认值 | 描述 |
|---|---|---|---|
prefix | string | 'o' | 每个令牌的名称前缀(o-s_1、--o-v_1)。 |
build.inline | boolean | false | 将 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-plugin | Vite 门面:编译、聚合、内联/输出 CSS、移除导入 |