docs/guide/theming
核心概念 · 主题

自定义主题

主题就是一个 ThemeTokens 值:颜色、间距、圆角、字号和控件尺度都在里面。从内置预设复制一份、改掉品牌相关的字段,再用 cx.setTheme 装上,就是你自己的主题。

预计阅读 8 分钟 · 截图来自真实窗口

这一页讲怎么定义和装配主题。样式函数、recipe 这些「怎么消费 token」的内容在 样式与主题。

默认主题与选择时机

每个窗口的 Cx 一开始指向 ui.theme.dark。不调用 setTheme 的应用就是暗色的——Hello Button 就是这样。要用别的主题,在挂载函数的第一行设置,赶在任何节点创建之前:

main.zig
fn mountUI(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
    // Cx starts on ui.theme.dark. Pick the theme before building any node,
    // so every component reads the right tokens when it mounts.
    cx.setTheme(&ui.theme.light);

    const root = try ui.boxStyled(cx, S.root, .{});
    // …
    return root;
}

多窗口时每个窗口有自己的 Cx,要在各自的挂载函数里设置。zenit 目前不会自动跟随系统外观:系统切换明暗只会触发一次重绘,也没有读取系统外观的公开 API。需要时给用户一个切换开关(见下文「运行时切换」)。

ThemeTokens 的结构

src/ui/theme.zig(节选)
pub const ThemeTokens = struct {
    name: []const u8 = "unnamed",
    scheme: ColorScheme = .light,      // .light / .dark — the one flag for "is this dark?"
    color: ColorTokens,                // 54 colors, no defaults: you must provide all of them
    space: SpaceScale = .{},           // _0 … _12  (0 … 48 px, 4 px grid)
    radius: RadiusScale = .{},         // none sm md lg xl full
    font_size: FontSizeScale = .{},    // xxs … xxxl (9 … 24)
    border_width: BorderWidthScale = .{},
    size: SizeTokens = .{},            // checkbox, switch, badge, dot, close button
    control: ControlScale = .{},       // xs / sm / md / lg metrics for every control
    duration: DurationTokens = .{},    // fast / normal / slow (seconds)
    shadow: ShadowTokens = .{},        // sm / md / lg
};

除了 color,其余字段都有默认值。ColorTokens 的 54 个颜色没有默认值,所以不要从零写一个 struct 字面量——从 ui.theme.light、ui.theme.dark 或 ui.theme.high_contrast 复制,只改你关心的字段。

从预设派生品牌主题

把主题写成容器级 const,初始化块在编译期求值;这样得到的是静态存储的值,cx.setTheme(&brand) 保存的指针在整个程序运行期间都有效。不要把主题放在函数的局部变量里再取地址。

theme.zig
// theme.zig — your app's themes. Container-level consts: they are
// evaluated at compile time and live for the whole program, which is
// what cx.setTheme(&brand) needs (it keeps the pointer).
const ui = @import("ui");

pub const brand: ui.ThemeTokens = blk: {
    var t = ui.theme.light; // copy a complete preset (every color is required)
    t.name = "brand";
    t.scheme = .light;

    const blue = ui.Color.hex(0x1144AA);
    t.color.accent = blue;                       // links, checked states, active tabs…
    t.color.accent_hover = ui.Color.hex(0x0D3688);
    t.color.accent_subtle = ui.Color.hex(0xE6ECF7);
    t.color.button_primary_bg = blue;            // primary buttons read these two,
    t.color.button_primary_fg = ui.Color.WHITE;  //   not accent
    t.color.border_focus = blue;
    t.color.selection_bg = ui.Color.hex(0xC9D6F0);
    t.color.list_selection_bg = ui.Color.hex(0xE6ECF7);

    t.radius.lg = 10;
    t.control.md.radius = 10;
    t.control.md.padding_h = 14;
    break :blk t;
};

配一套暗色

暗色版本从 ui.theme.dark 派生,而不是在亮色品牌主题上改背景:暗色预设里的几十个背景、文字、边框值是成套调过的。品牌色在深色背景上通常要提亮,primary 按钮的文字改成深色。

theme.zig
pub const brand_dark: ui.ThemeTokens = blk: {
    var t = ui.theme.dark; // start from the dark preset, not from brand
    t.name = "brand_dark";
    t.scheme = .dark;      // Notification palettes and DevTools key off this

    const blue = ui.Color.hex(0x7FA2F0); // lighter on dark backgrounds
    t.color.accent = blue;
    t.color.button_primary_bg = blue;
    t.color.button_primary_fg = ui.Color.hex(0x0D1119);
    t.color.border_focus = blue;
    break :blk t;
};
brand(亮)brand(亮)
brand_darkbrand_dark
ui.theme.dark(默认)ui.theme.dark(默认)
同一个 Hello Button,只换了 setTheme 的参数。三张都是 E2E harness 在真实窗口里截的图。

哪些 token 影响哪些组件

下表按组件实际读取的字段整理(v0.1.0-alpha 源码),改主题时先改这些:

Token主要用在
fg_primary · fg_secondary · fg_tertiary · fg_disabled几乎所有文字与图标
bg_primary · bg_secondary · bg_tertiary面板、卡片、Calendar、Chip、Progress / Slider 轨道、Skeleton
bg_hover · bg_active可点击控件的悬停与按下
accentCheckbox / Switch 选中、链接、Tabs、Breadcrumb、日期选中、Badge
button_primary_bg · button_primary_fgprimary Button(hover / 按下从背景推导);fg 也用于 Badge、Chip、Tabs 上的反色文字
border · border_strong · separator边框与分隔线
border_focus · input_bg · input_border · selection_bgInput、Select、NumberStepper、DatePicker 等输入类控件
success · warning · danger · info · status_fgAlert、Badge、Tag、Progress、danger Button
list_hover_bg · list_selection_bgMenu、DropdownMenu、ComboBox、Table、Tree、DataTable
checkbox_* · switch_thumb · switch_track_offCheckbox 与 Switch 的未选中、禁用态
tooltip_bg · tooltip_fg · overlay · scrollbar_thumbTooltip、Modal / Sheet 遮罩、滚动条

少数组件带有自己的配色,不从 ColorTokens 取色:Notification 按 scheme 在内置的亮 / 暗两套调色板之间选择;GlassBox 的玻璃着色、Progress 与 Select 的部分细节也是固定值。它们会随明暗切换,但不会换成你的品牌色。

圆角、间距与控件尺度

非颜色的 token 可以按字段覆盖。最有用的是 control:Button、Input、Select、ComboBox、DatePicker、DateRangePicker、Tabs、Chip 共享 xs / sm / md / lg 四档度量。控件的外框高度不是 token,而是算出来的:

档位默认高度 = padding_y × 2 + font_size × line_height
xs2.5 × 2 + 12 × 1.25 = 20
sm4.5 × 2 + 12 × 1.25 = 24
md7.25 × 2 + 14 × 1.25 = 32
lg10 × 2 + 16 × 1.25 = 40

所以想让 md 控件更高,就改 t.control.md.padding_y 或字号,同档的所有控件会一起变;不要去给控件写死 height。图标尺寸 icon_size 要保持不大于行高。

运行时切换

cx.setTheme 可以随时调用:它替换 token 指针,在 cx.root 子树(包括浮层 portal)上重放 on_theme / before_render hook 并请求重绘。

prefs.zig
const Prefs = struct {
    cx: *ui.Cx,
    dark: bool = false,

    pub fn toggle(self: *Prefs) void {
        self.dark = !self.dark;
        self.cx.setTheme(if (self.dark) &themes.brand_dark else &themes.brand);
    }
};

// In mountUI:
const prefs = try cx.bindState(Prefs, .{ .cx = cx });
cx.setTheme(&themes.brand);
// …
.on_click = cx.on(Prefs, prefs, Prefs.toggle),
reactive read
// A reactive read of the current theme (lives as long as `scope`).
const theme_sig = try cx.themeSignal(scope);
const is_dark = theme_sig.get().scheme == .dark;

发布前检查

  • ✓

    scheme 与背景明暗一致(暗底必须是 .dark)。

  • ✓

    button_primary_fg 与 button_primary_bg、status_fg 与 danger 的对比度至少 4.5:1——内置预设的单元测试就是这样检查的。

  • ✓

    border 与 bg_primary 仍然分得清(预设测试要求对比度 ≥ 1.2)。

  • ✓

    在 Storybook 里切到你的主题,把常用组件都点一遍(可以参考 Component Wall story)。

zenit · 双授权开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。可联系作者:zongyi.xzy#gmail.com(# 换成 @)zenit 5f9add5+wip 2026-09-30