スタイルとテーマ
Token、名前付きスタイル関数、Recipe を使い、見た目とビジネスロジックを互いに独立させます。
3 層スタイルモデル
色、文字サイズ、余白、角丸、コントロール寸法の唯一の情報源。
名前を付け、再利用し、テストでき、テーマ変更時に再実行される純粋関数。
バリアント、インタラクションの条件状態、複数パーツのコンポーネントを扱います。
Token ファースト
スタイル値は cx.tokens(またはスタイル関数が受け取る t)から読み取ります。本当にスケール外のデザイン値は ui.arb で明示します。実行時にはコストゼロの恒等関数で、価値は意味づけにあります。読み手は「意図的な任意値」と「token に移すべき手抜きのリテラル」をひと目で区別できます。
// Preferred: follows the theme
.background = cx.tokens.color.bg_primary,
.padding = ui.Padding.all(cx.tokens.space._4),
.font_size = cx.tokens.font_size.xl,
// Legitimate escape hatch: states that the value is intentionally off-scale
.font_size = ui.arb.px(18),
.background = ui.arb.hex(0x316ff6),
.border_color = ui.arb.hexA(0x000000, 0.12),名前付きスタイル関数
スタイル関数のシグネチャは fn (*const ui.ThemeTokens) ui.BoxStyle(または ui.TextStyle)です。styles.zig にまとめ、ビューからは名前で参照します。
const ui = @import("ui");
pub fn card(t: *const ui.ThemeTokens) ui.BoxStyle {
return .{
.background = t.color.bg_secondary,
.padding = ui.Padding.all(t.space._6),
.gap = t.space._3,
};
}
pub fn row(t: *const ui.ThemeTokens) ui.BoxStyle {
return .{ .gap = t.space._2 };
}
pub fn title(t: *const ui.ThemeTokens) ui.TextStyle {
return .{
.font_size = t.font_size.xxl,
.font_weight = 600,
.color = t.color.fg_primary,
};
}const S = @import("styles.zig");
const card = try ui.boxStyled(cx, S.card, .{});
try card.appendChild(
cx.allocator,
try ui.textStyled(cx, S.title, "Workspace"),
);
// hstackStyled / vstackStyled force direction = .row / .column.
const actions = try ui.hstackStyled(cx, S.row, .{});
try card.appendChild(cx.allocator, actions);boxStyled、textStyled、hstackStyled、vstackStyled はノードに on_theme hook を登録し、スタイル関数そのものを記憶します。テーマが変わると、ノードは新しい token でスタイルを再計算します。通常の ui.box 内で読んだ token は、マウント時のスナップショットにすぎません。
テーマの切り替え
組み込みテーマは ui.theme.light、ui.theme.dark、ui.theme.high_contrast の 3 つです。cx.setTheme は token ポインタを差し替え、cx.root サブツリーを走査して各ノードの on_theme と before_render hook を再実行し、dirty にします。現在のテーマをリアクティブに読むには cx.themeSignal(scope) を使います。
// Built-in palettes: ui.theme.light, ui.theme.dark, ui.theme.high_contrast
cx.setTheme(&ui.theme.dark);
// Reactive access: a Signal that setTheme updates.
const theme = try cx.themeSignal(scope);
try scope.createEffect(.{ .theme = theme }, struct {
fn run(ctx: anytype) void {
const t = ctx.theme.get();
std.log.info("theme: {s} ({s})", .{ t.name, @tagName(t.scheme) });
}
}.run);明暗の違いは完全な token パレットで表現すべきです。スタイル関数内で本当に分岐が必要なときだけ t.scheme(.light / .dark)を読みます。
ブランドカラーの適用や独自のライト / ダークテーマの作成については、カスタムテーマを参照してください。
Recipe と条件状態
ui.recipe は名前空間です。ui.recipe.recipe(Config) は単一ノードの recipe を、ui.recipe.slotRecipe(Config) は複数パーツの recipe を定義します。解決時は base → variants → derived → compounds の順に合成され、後から書かれた非 null フィールドが優先されます。コンポーネント外部からの style override は最後に適用されます。
Variants必須。各フィールドがバリアントの次元(enum または bool)で、既定値を持つbase(t)任意。ベースとなる ConditionalStylevariants任意。次元ごとに 1 つの resolver:fn (value, t) ConditionalStylederived(v, t)任意。Variants 全体を受け取り、次元をまたぐ連続的なロジックを表す(例:padding = f(size, アイコンモード))compounds任意。{ matches(v), style(t) }。複数の次元が同時に一致したときに重ねて適用以下はリポジトリ内 examples/hello_button/styles.zig の実際のコードです。アプリ側でも独自の recipe を定義し、resolveBase でバリアントを固定して ui.boxStyled に渡せるスタイル関数にできます。
pub const PanelEmphasis = enum { neutral, highlight };
/// CVA-style: base + one variant dimension, all from tokens.
const PanelRecipe = ui.recipe.recipe(struct {
pub const Variants = struct {
emphasis: PanelEmphasis = .neutral,
};
pub fn base(t: *const ui.ThemeTokens) ui.ConditionalStyle {
return .{ .base = .{
.padding = ui.Padding.symmetric(t.space._2, t.space._4),
.corner_radius = t.radius.md,
} };
}
pub const variants = .{
.emphasis = struct {
fn resolve(e: PanelEmphasis, t: *const ui.ThemeTokens) ui.ConditionalStyle {
return switch (e) {
.neutral => .{ .base = .{ .background = t.color.bg_secondary } },
.highlight => .{ .base = .{
.background = t.color.bg_tertiary,
.border = .{ .width = 1, .color = t.color.accent, .radius = 0 },
} },
};
}
}.resolve,
};
});
/// Freeze a variant into a theme-safe style function for ui.boxStyled.
pub fn panel(comptime emphasis: PanelEmphasis) fn (*const ui.ThemeTokens) ui.BoxStyle {
return struct {
fn f(t: *const ui.ThemeTokens) ui.BoxStyle {
return PanelRecipe.resolveBase(.{ .emphasis = emphasis }, t);
}
}.f;
}ConditionalStyle は base のほかに 7 つの条件スロットを持ちます:selected、expanded、hover、active、focus、invalid、disabled。resolve(state) は selected → expanded → hover → active → focus → invalid の順に重ねます。disabled はショートサーキットし、無効化されたノードには他の条件は一切重なりません。
// Full ConditionalStyle for one variant combination.
const cs = PanelRecipe.resolve(.{ .emphasis = .highlight }, cx.tokens);
// A component's style props are applied last, on top of the recipe output.
const merged = cs.override(.{
.style = .{ .background = cx.tokens.color.accent },
});
// Pick the final style for the current interaction state.
const final: ui.BoxStyle = merged.resolve(.{ .is_hovered = true });ui.recipe.recipeui.recipe.slotRecipeui.ConditionalStylet.scheme を読む現在の制約
- ✓
コンポーネントの多くはマウント時に token を読みます。
ui.widgets.*のうち token を再読み込みする hook を登録していない部分は、マウント時のスナップショットを保持します。実行時にテーマを切り替えると、アプリ側の styled ノードは再実行されますが、コンポーネントライブラリのノードはツリーの再構築が必要になる場合があります。 - ✓
再実行は cx.root サブツリーのみが対象です。ウィンドウのオーバーレイ portal はその中にあります。
cx.rootから切り離されたツリーは更新されません。 - ✓
themeSignal はそれを作成した Scope に従います。すべての購読者より長く生きるよう、アプリのルート Scope で作成してください。
各コンポーネントの variant とサイズのマトリクスはコンポーネントサイトを参照してください。