v0.1.0-alpha
官网Home
docs/guide/styling
核心概念 · Design systemCore concepts · Design system

样式与主题Styling & themes

用 Token、具名样式函数与 Recipe,让视觉和业务逻辑彼此独立。Use tokens, named style functions and recipes so visuals and business logic stay independent.

预计阅读 12 分钟12 min read

三层样式模型The three-layer model

01
01 · ThemeTokens01 · ThemeTokens

颜色、字号、间距、圆角与控件尺度的唯一来源。The single source for colors, type sizes, spacing, radii and control metrics.

02
02 · styles.zig02 · styles.zig

可命名、复用、测试,并在换主题时重放的纯函数。Pure functions you can name, reuse, test — and replay when the theme changes.

03
03 · Recipe03 · Recipe

处理变体、交互条件态与多部件组件。Handles variants, interaction conditions and multi-part components.

STYLE LAYERS
每一层只读上一层:业务视图从不直接写颜色值,换主题时也就只需换掉最底层的 token。Each layer reads only the one below it: views never hard-code a color, so switching themes means swapping only the bottom layer.
Button 的 variant × size、图标、loading、disabled 与 block 矩阵,样式由 recipe 解析。Button’s variant × size, icon, loading, disabled and block matrix, styled through a recipe.
GlassBox 的 regular、interactive、clear 与真实 backdrop blur。GlassBox in regular, interactive and clear modes with a real backdrop blur.

Token 优先Tokens first

样式值从 cx.tokens(或样式函数收到的 t)读取。确实不在刻度上的设计值,用 ui.arb 显式标出:它在运行时是零开销的恒等函数,价值在于语义——读者一眼能区分「有意的任意值」与「该迁到 token 的偷懒字面量」。Read style values from cx.tokens (or the t a style function receives). For design values that genuinely sit off the scale, mark them with ui.arb: it is a zero-cost identity at runtime, and its value is semantic — readers can tell an intentional arbitrary value from a lazy literal that should become a 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),

具名样式函数Named style functions

样式函数的签名是 fn (*const ui.ThemeTokens) ui.BoxStyle(或 ui.TextStyle)。把它们集中在 styles.zig,视图只按名字引用。A style function has the signature fn (*const ui.ThemeTokens) ui.BoxStyle (or ui.TextStyle). Keep them together in styles.zig and let views refer to them by name.

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 只是挂载时的快照。boxStyled, textStyled, hstackStyled and vstackStyled register an on_theme hook on the node that remembers the style function itself. When the theme changes, the node recomputes its style from the new tokens; tokens read inside a plain ui.box are just a snapshot from mount time.

切换主题Switching themes

内置三套主题:ui.theme.light、ui.theme.dark、ui.theme.high_contrast。cx.setTheme 替换 token 指针,遍历 cx.root 子树重放每个节点的 on_theme 与 before_render hook 并标脏;需要响应式地读当前主题时用 cx.themeSignal(scope)。Three themes ship built in: ui.theme.light, ui.theme.dark and ui.theme.high_contrast. cx.setTheme swaps the token pointer, walks the cx.root subtree replaying every node’s on_theme and before_render hooks, and marks them dirty. For reactive access to the current theme, use 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 palette 表达;只有在样式函数里确实需要分支时,才读取 t.scheme(.light / .dark)。Light/dark differences belong in the full token palette; read t.scheme (.light / .dark) inside a style function only when you truly need a branch.

想换品牌色、做一套自己的亮 / 暗主题,见 自定义主题。To apply your brand color or build your own light / dark themes, see Custom themes.

Recipe 与条件态Recipes & conditions

ui.recipe 是一个命名空间:ui.recipe.recipe(Config) 定义单节点配方,ui.recipe.slotRecipe(Config) 定义多部件配方。解析时按 base → variants → derived → compounds 合并,后写入的非空字段覆盖先前的值;组件的外部 style override 最后应用。ui.recipe is a namespace: ui.recipe.recipe(Config) defines a single-node recipe and ui.recipe.slotRecipe(Config) a multi-part one. Resolution merges base → variants → derived → compounds, with later non-null fields winning; a component’s external style override is applied last.

RECIPE MERGE
每一步只覆盖自己写了的字段。derived 按约定只产出几何字段(padding、圆角、尺寸),不碰 background。Each step overwrites only the fields it sets. By convention derived produces geometry only (padding, radius, size) and never touches background.
Config 成员Config member作用Role
Variants必需。每个字段是一个变体维度(enum 或 bool),带默认值Required. Each field is a variant dimension (enum or bool) with a default
base(t)可选。基础 ConditionalStyleOptional. The base ConditionalStyle
variants可选。每个维度一个 resolver:fn (value, t) ConditionalStyleOptional. One resolver per dimension: fn (value, t) ConditionalStyle
derived(v, t)可选。拿到完整 Variants,表达跨维度的连续组合(如 padding = f(size, icon 模式))Optional. Sees all Variants, for cross-dimension continuous logic (e.g. padding = f(size, icon mode))
compounds可选。{ matches(v), style(t) },多个维度同时满足时叠加Optional. { matches(v), style(t) } applied when several dimensions match together

下面是仓库里 examples/hello_button/styles.zig 的真实写法:应用层同样可以定义自己的 recipe,再用 resolveBase 固化成可交给 ui.boxStyled 的样式函数。Here is the real code from examples/hello_button/styles.zig: apps can define their own recipes too, then freeze a variant with resolveBase into a style function that ui.boxStyled accepts.

ConditionalStyle 在 base 之外还带七个条件位:selected、expanded、hover、active、focus、invalid、disabled。resolve(state) 依次叠加 selected → expanded → hover → active → focus → invalid;disabled 短路,禁用时不再叠加任何其他条件。Besides base, a ConditionalStyle carries seven condition slots: selected, expanded, hover, active, focus, invalid, disabled. resolve(state) layers selected → expanded → hover → active → focus → invalid in that order; disabled short-circuits, so nothing else stacks on a disabled node.

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 });
场景Situation工具Tool
单一静态样式One static style具名样式函数Named style function
尺寸 / 视觉变体Size / visual variantsui.recipe.recipe
输入框、菜单等多部件组件Multi-part components (inputs, menus)ui.recipe.slotRecipe
hover / disabled / invalidhover / disabled / invalidui.ConditionalStyle
明暗主题Light / dark完整 token palette;必要时读 t.schemeA full token palette; read t.scheme if you must

当前边界Current boundaries

  • ✓

    组件多在 mount 时读 token。ui.widgets.* 中没有挂 hook 重读 token 的部分,保留的是挂载时的快照。运行时切换主题后,应用层 styled 节点会重放,组件库节点可能需要重建树。Components mostly read tokens at mount. Parts of ui.widgets.* that don’t register a hook to re-read tokens keep their mount-time snapshot. After a runtime theme switch, app-level styled nodes replay, but component-library nodes may need the tree rebuilt.

  • ✓

    重放只覆盖 cx.root 子树。窗口浮层 portal 在其中;脱离 cx.root 的独立树不会更新。Replay covers the cx.root subtree. The window overlay portal is inside it; trees detached from cx.root are not updated.

  • ✓

    themeSignal 跟随创建它的 Scope。建议在应用根 Scope 上创建,保证它活得比所有订阅方久。themeSignal follows the Scope that created it. Create it on the app’s root Scope so it outlives every subscriber.

组件的完整 variant 与尺寸矩阵见 组件站。See the component site for every component’s variant and size matrix.

zenit · 双授权Dual-licensed开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。Free for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.可联系作者:Contact the author: zongyi.xzy#gmail.com(# 换成 @) (replace # with @)zenit 5f9add5+wip 2026-09-30