---
title: "自定义主题 — zenit Zig UI 文档"
description: "主题就是一个 ThemeTokens 值：颜色、间距、圆角、字号和控件尺度都在里面。从内置预设复制一份、改掉品牌相关的字段，再用 cx.setTheme 装上，就是你自己的主…"
url: https://zenit.z.express/zh/docs/guide/theming
language: zh-CN
alternate_en: https://zenit.z.express/docs/guide/theming.md
alternate_es: https://zenit.z.express/es/docs/guide/theming.md
alternate_ja: https://zenit.z.express/ja/docs/guide/theming.md
alternate_ko: https://zenit.z.express/ko/docs/guide/theming.md
alternate_fr: https://zenit.z.express/fr/docs/guide/theming.md
alternate_de: https://zenit.z.express/de/docs/guide/theming.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# 自定义主题

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

这一页讲怎么定义和装配主题。样式函数、recipe 这些「怎么消费 token」的内容在 [样式与主题](https://zenit.z.express/zh/docs/guide/styling)。

## 默认主题与选择时机

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

`main.zig`

```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（节选）`

```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` 复制，只改你关心的字段。

> NOTE
> 
> **判断明暗只看 scheme。** `scheme` 是唯一的明暗标志：Notification 按它选调色板，DevTools 按它推导自己的配色。不要根据 `name` 里有没有 “dark” 来判断——高对比主题是纯黑底，名字里并没有 dark。

## 从预设派生品牌主题

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

`theme.zig`

```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;
};
```

> WARNING
> 
> **组件 token 不会跟着 accent 走。** 预设里 `accent` 和 `button_primary_bg` 是两个独立的值，没有推导关系。改品牌色时，`accent`、`button_primary_bg` / `button_primary_fg`、`border_focus` 要一起改，否则 primary 按钮会停留在预设颜色。

## 配一套暗色

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

`theme.zig`

```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（亮）](https://zenit.z.express/media/theme-brand.webp?v=588f003545)brand（亮）

![brand_dark](https://zenit.z.express/media/theme-brand-dark.webp?v=854df7b29f)brand\_dark

![ui.theme.dark（默认）](https://zenit.z.express/media/theme-default-dark.webp?v=3b5b953dc1)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` | 可点击控件的悬停与按下 |
| `accent` | Checkbox / Switch 选中、链接、Tabs、Breadcrumb、日期选中、Badge |
| `button_primary_bg · button_primary_fg` | primary Button（hover / 按下从背景推导）；fg 也用于 Badge、Chip、Tabs 上的反色文字 |
| `border · border_strong · separator` | 边框与分隔线 |
| `border_focus · input_bg · input_border · selection_bg` | Input、Select、NumberStepper、DatePicker 等输入类控件 |
| `success · warning · danger · info · status_fg` | Alert、Badge、Tag、Progress、danger Button |
| `list_hover_bg · list_selection_bg` | Menu、DropdownMenu、ComboBox、Table、Tree、DataTable |
| `checkbox_* · switch_thumb · switch_track_off` | Checkbox 与 Switch 的未选中、禁用态 |
| `tooltip_bg · tooltip_fg · overlay · scrollbar_thumb` | Tooltip、Modal / Sheet 遮罩、滚动条 |

> WARNING
> 
> **这些 token 目前没有组件读取。** `accent_muted`、`bg_inset`、`button_secondary_bg`、`button_secondary_fg`、`switch_track_on`（Switch 开启态用的是 `accent`）、`table_header_bg`、`list_selection_hover_bg`，以及 `success_subtle` / `warning_subtle` / `danger_subtle` / `info_subtle`。改它们现在不会有可见变化；你自己的样式函数可以照常使用。

少数组件带有自己的配色，不从 `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 |
| --- | --- |
| `xs` | 2.5 × 2 + 12 × 1.25 = 20 |
| `sm` | 4.5 × 2 + 12 × 1.25 = 24 |
| `md` | 7.25 × 2 + 14 × 1.25 = 32 |
| `lg` | 10 × 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`

```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),
```

```zig
// 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;
```

> NOTE
> 
> **运行时切换的范围。** 用 `boxStyled` / `textStyled` 创建的节点和注册了主题 hook 的组件会立即换色；多数 `ui.widgets.*` 在挂载时读取 token，要完整换装需要重建对应的子树。只在启动时选定主题的应用不受影响。

## 发布前检查

-   `scheme` 与背景明暗一致（暗底必须是 `.dark`）。
    
-   `button_primary_fg` 与 `button_primary_bg`、`status_fg` 与 `danger` 的对比度至少 4.5:1——内置预设的单元测试就是这样检查的。
    
-   `border` 与 `bg_primary` 仍然分得清（预设测试要求对比度 ≥ 1.2）。
    
-   在 Storybook 里切到你的主题，把常用组件都点一遍（可以参考 Component Wall story）。
