Руководство
CSS-Zero позволяет писать CSS-стили в специальных контрактных файлах (.css.ts/.css.js). На этапе сборки компилятор выполняет эти файлы, заменяет каждый вызов утилиты на детерминированный строковый токен и генерирует соответствующий CSS единым чанком. В браузере ничего не выполняется.
CSS-Zero вдохновлён vanilla-extract (типобезопасное написание .css.ts с нулевым рантаймом) и Tailwind CSS (компонуемые утилитарные/вариантные классы), объединяя все это в одной идеей - переиспользовании токенов.
Почему это переиспользуемый CSS-in-TS?
На мой взгляд, ценность кода часто можно измерить его переиспользованием. 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, который вы реально используете, и всё это без накладных расходов в рантайме.
В итоге вы описываете стили один раз, переиспользуете где угодно и получаете в сборке только то, что используете.
Установка
npm i @css-zero/core @css-zero/vite-pluginНастройка (Vite)
// 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 и импортируйте токены в свои компоненты.
// 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
import { className } from '@css-zero/core';
export const btn = className({
color: 'white',
'&:hover': { color: 'gray' },
});
// → 'o-s_1'classSelector
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
import { id } from '@css-zero/core';
export const badge = id({ backgroundColor: 'red' });
// → 'o-i_1'idSelector
import { idSelector } from '@css-zero/core';
export const [modal, modalSelector] = idSelector({
position: 'fixed',
inset: 0,
});
// modal → 'o-i_1', modalSelector → '#o-i_1'variable
import { variable } from '@css-zero/core';
// [name, var(name)]
export const [accentKey, accent] = variable('#2b6cb0');
// → ['--o-v_1', 'var(--o-v_1)']animation
import { animation } from '@css-zero/core';
export const spin = animation({
from: { transform: 'rotate(0)' },
to: { transform: 'rotate(360deg)' },
});font
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
import { layer } from '@css-zero/core';
export const base = layer();
// → '@layer o-l_1'container
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
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
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
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-свойства через слияние объявлений интерфейсов:
declare global {
namespace CSSZero {
interface ExtraProperties {
'--brand': string;
}
}
}Публикация библиотеки стилей
Контрактные модули — это обычные модули, поэтому вы можете опубликовать стили как NPM-пакет и использовать их:
// 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>Опции плагина
| Опция | Тип | По умолчанию | Описание |
|---|---|---|---|
prefix | string | 'o' | Префикс имени для каждого токена (o-s_1, --o-v_1). |
build.inline | boolean | false | Встраивать CSS в тег <style> в <head> вместо <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, удаляет импорты |