docs/guide/styling
コアコンセプト · Design system

スタイルとテーマ

Token、名前付きスタイル関数、Recipe を使い、見た目とビジネスロジックを互いに独立させます。

約 12 分で読めます

3 層スタイルモデル

01
01 · ThemeTokens

色、文字サイズ、余白、角丸、コントロール寸法の唯一の情報源。

02
02 · styles.zig

名前を付け、再利用し、テストでき、テーマ変更時に再実行される純粋関数。

03
03 · Recipe

バリアント、インタラクションの条件状態、複数パーツのコンポーネントを扱います。

STYLE LAYERS
各層はすぐ下の層だけを読みます。ビューは色を直書きしないので、テーマの切り替えは最下層を差し替えるだけで済みます。
Button の variant × size、アイコン、loading、disabled、block のマトリクス。スタイルは recipe で解決されます。
GlassBox の regular・interactive・clear モードと本物の backdrop blur。

Token ファースト

スタイル値は cx.tokens(またはスタイル関数が受け取る t)から読み取ります。本当にスケール外のデザイン値は ui.arb で明示します。実行時にはコストゼロの恒等関数で、価値は意味づけにあります。読み手は「意図的な任意値」と「token に移すべき手抜きのリテラル」をひと目で区別できます。

zig
// 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 にまとめ、ビューからは名前で参照します。

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,
    };
}
view.zig
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) を使います。

theme.zig
// 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 は最後に適用されます。

RECIPE MERGE
各ステップは自分が設定したフィールドだけを上書きします。慣例として derived はジオメトリ(padding、角丸、サイズ)だけを出力し、background には触れません。
Config メンバー役割
Variants必須。各フィールドがバリアントの次元(enum または bool)で、既定値を持つ
base(t)任意。ベースとなる ConditionalStyle
variants任意。次元ごとに 1 つの resolver:fn (value, t) ConditionalStyle
derived(v, t)任意。Variants 全体を受け取り、次元をまたぐ連続的なロジックを表す(例:padding = f(size, アイコンモード))
compounds任意。{ matches(v), style(t) }。複数の次元が同時に一致したときに重ねて適用

以下はリポジトリ内 examples/hello_button/styles.zig の実際のコードです。アプリ側でも独自の recipe を定義し、resolveBase でバリアントを固定して ui.boxStyled に渡せるスタイル関数にできます。

ConditionalStyle は base のほかに 7 つの条件スロットを持ちます:selected、expanded、hover、active、focus、invalid、disabled。resolve(state) は selected → expanded → hover → active → focus → invalid の順に重ねます。disabled はショートサーキットし、無効化されたノードには他の条件は一切重なりません。

resolve.zig
// 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.recipe
複数パーツのコンポーネント(入力欄、メニュー)ui.recipe.slotRecipe
hover / disabled / invalidui.ConditionalStyle
ライト / ダーク完全な token パレット。必要なら t.scheme を読む

現在の制約

  • ✓

    コンポーネントの多くはマウント時に token を読みます。ui.widgets.* のうち token を再読み込みする hook を登録していない部分は、マウント時のスナップショットを保持します。実行時にテーマを切り替えると、アプリ側の styled ノードは再実行されますが、コンポーネントライブラリのノードはツリーの再構築が必要になる場合があります。

  • ✓

    再実行は cx.root サブツリーのみが対象です。ウィンドウのオーバーレイ portal はその中にあります。cx.root から切り離されたツリーは更新されません。

  • ✓

    themeSignal はそれを作成した Scope に従います。すべての購読者より長く生きるよう、アプリのルート Scope で作成してください。

各コンポーネントの variant とサイズのマトリクスはコンポーネントサイトを参照してください。

zenit · デュアルライセンスオープンソースプロジェクトは GPL-3.0-only のもとで無料で使えます。クローズドソースや商用製品には商用ライセンスが必要です。作者への連絡先:zongyi.xzy#gmail.com(# を @ に置き換え)zenit 5f9add5+wip 2026-09-30