Styling & themes
Use tokens, named style functions and recipes so visuals and business logic stay independent.
The three-layer model
The single source for colors, type sizes, spacing, radii and control metrics.
Pure functions you can name, reuse, test and replay when the theme changes.
Handles variants, interaction conditions and multi-part components.
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.
// 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.
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,
};
}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).
// 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.
VariantsRequired. Each field is a variant dimension (enum or bool) with a defaultbase(t)Optional. The base ConditionalStylevariantsOptional. One resolver per dimension: fn (value, t) ConditionalStylederived(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 togetherHere 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.
pub const PanelEmphasis = enum { neutral, highlight };
/// CVA-style: base + one variant dimension, all from tokens.
const PanelRecipe = ui.recipe.recipe(struct {
pub const Variants = struct {
emphasis: PanelEmphasis = .neutral,
};
pub fn base(t: *const ui.ThemeTokens) ui.ConditionalStyle {
return .{ .base = .{
.padding = ui.Padding.symmetric(t.space._2, t.space._4),
.corner_radius = t.radius.md,
} };
}
pub const variants = .{
.emphasis = struct {
fn resolve(e: PanelEmphasis, t: *const ui.ThemeTokens) ui.ConditionalStyle {
return switch (e) {
.neutral => .{ .base = .{ .background = t.color.bg_secondary } },
.highlight => .{ .base = .{
.background = t.color.bg_tertiary,
.border = .{ .width = 1, .color = t.color.accent, .radius = 0 },
} },
};
}
}.resolve,
};
});
/// Freeze a variant into a theme-safe style function for ui.boxStyled.
pub fn panel(comptime emphasis: PanelEmphasis) fn (*const ui.ThemeTokens) ui.BoxStyle {
return struct {
fn f(t: *const ui.ThemeTokens) ui.BoxStyle {
return PanelRecipe.resolveBase(.{ .emphasis = emphasis }, t);
}
}.f;
}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.
// 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.recipeui.recipe.slotRecipeui.ConditionalStylet.scheme if you mustCurrent 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.rootare 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.