ガイド
CSS-Zero を使うと、コントラクトファイル(.css.ts/.css.js)で CSS スタイルを記述できます。ビルド時にコンパイラがこれらのファイルを実行し、各ユーティリティ呼び出しを決定的な文字列トークンに置き換えて、対応する 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は、単一の基本スタイルを一連のモディファイアに変えるため、1 つのコンポーネントで 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 | <link> の代わりに <head> 内の <style> タグに CSS をインライン化する |
重要
バンドラーの外(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 のインライン化/出力、インポートの除去 |