docs/guide/styling
Core concepts · Design system

Styling & themes

Use tokens, named style functions and recipes so visuals and business logic stay independent.

12 min read

The three-layer model

01
01 · ThemeTokens

The single source for colors, type sizes, spacing, radii and control metrics.

02
02 · styles.zig

Pure functions you can name, reuse, test and replay when the theme changes.

03
03 · Recipe

Handles variants, interaction conditions and multi-part components.

STYLE LAYERS
Each layer reads only the one below it: views never hard-code a color, so switching themes means swapping only the bottom layer.
Button’s variant × size, icon, loading, disabled and block matrix, styled through a recipe.
GlassBox in regular, interactive and clear modes with a real backdrop blur.

Tokens first

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

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 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

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);

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.

Recipes & conditions

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
Each step overwrites only the fields it sets. By convention derived produces geometry only (padding, radius, size) and never touches background.
Config memberRole
VariantsRequired. Each field is a variant dimension (enum or bool) with a default
base(t)Optional. The base ConditionalStyle
variantsOptional. One resolver per dimension: fn (value, t) ConditionalStyle
derived(v, t)Optional. Sees all Variants, for cross-dimension continuous logic (e.g. padding = f(size, icon mode))
compoundsOptional. { matches(v), style(t) } applied when several dimensions match together

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.

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 });
SituationTool
One static styleNamed style function
Size / visual variantsui.recipe.recipe
Multi-part components (inputs, menus)ui.recipe.slotRecipe
hover / disabled / invalidui.ConditionalStyle
Light / darkA full token palette; read t.scheme if you must

Current boundaries

  • ✓

    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.

  • ✓

    Replay covers the cx.root subtree. The window overlay portal is inside it; trees detached from cx.root are not updated.

  • ✓

    themeSignal follows the Scope that created it. Create it on the app’s root Scope so it outlives every subscriber.

See the component site for every component’s variant and size matrix.

zenit · Dual-licensedFree 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 4c50245 2026-10-01