Custom themes
A theme is a single ThemeTokens value: colors, spacing, radii, type sizes and control metrics. Copy a built-in preset, change the fields that carry your brand, install it with cx.setTheme, and it is your theme.
This page is about defining and installing a theme. How views consume tokens (style functions and recipes) is covered in Styling & themes.
The default theme
Every window’s Cx starts on ui.theme.dark. An app that never calls setTheme is dark, like Hello Button. To use another theme, set it on the first line of your mount function, before any node exists:
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;
}With several windows, each has its own Cx, so set the theme in each mount function. zenit does not follow the system appearance yet: a system light/dark change only triggers a redraw, and there is no public API to read the system appearance. Offer your users a toggle instead (see “Switching at runtime”).
Anatomy of ThemeTokens
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
};Everything but color has defaults. The 54 colors in ColorTokens have none, so don’t write a struct literal from scratch. Copy ui.theme.light, ui.theme.dark or ui.theme.high_contrast and change only what you care about.
Deriving a brand theme
Write themes as container-level consts; the initializer block runs at compile time and the result has static storage, so the pointer cx.setTheme(&brand) keeps stays valid for the life of the program. Never take the address of a theme held in a function’s local variable.
// 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;
};A matching dark theme
Derive the dark variant from ui.theme.dark rather than recoloring the light brand theme: the dozens of background, text and border values in the dark preset are tuned as a set. On dark backgrounds the brand color usually needs lifting, and primary-button text turns dark.
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 (light)
brand_dark
ui.theme.dark (default)Which tokens drive what
Grouped by what components actually read (v0.1.0-alpha source). Start with these when you theme:
fg_primary · fg_secondary · fg_tertiary · fg_disabledNearly all text and iconsbg_primary · bg_secondary · bg_tertiaryPanels, cards, Calendar, Chip, Progress / Slider tracks, Skeletonbg_hover · bg_activeHover and pressed states of clickable controlsaccentChecked Checkbox / Switch, links, Tabs, Breadcrumb, selected dates, Badgebutton_primary_bg · button_primary_fgPrimary Button (hover / pressed derive from the background); fg also inverts text on Badge, Chip and Tabsborder · border_strong · separatorBorders and dividersborder_focus · input_bg · input_border · selection_bgInput, Select, NumberStepper, DatePicker and other fieldssuccess · warning · danger · info · status_fgAlert, Badge, Tag, Progress, danger Buttonlist_hover_bg · list_selection_bgMenu, DropdownMenu, ComboBox, Table, Tree, DataTablecheckbox_* · switch_thumb · switch_track_offUnchecked and disabled Checkbox / Switchtooltip_bg · tooltip_fg · overlay · scrollbar_thumbTooltip, Modal / Sheet scrim, scrollbarsA few components carry their own colors instead of reading ColorTokens: the Notifier picks between its built-in light and dark palettes by scheme, and GlassBox tints plus some Progress and Select details are fixed values. They follow light/dark, but not your brand color.
Radii, spacing and control metrics
Non-color tokens can be overridden field by field. The most useful is control: Button, Input, Select, ComboBox, DatePicker, DateRangePicker, Tabs and Chip share four metric sets, xs / sm / md / lg. A control’s height is not a token. It is computed:
xs2.5 × 2 + 12 × 1.25 = 20sm4.5 × 2 + 12 × 1.25 = 24md7.25 × 2 + 14 × 1.25 = 32lg10 × 2 + 16 × 1.25 = 40So to make md controls taller, change t.control.md.padding_y or the font size and every md control follows; don’t hard-code a control height. Keep icon_size no larger than the line height.
Switching at runtime
cx.setTheme can be called at any time: it swaps the token pointer, replays on_theme / before_render hooks across the cx.root subtree (overlay portal included) and requests a redraw.
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),// 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;Before you ship
- ✓
schemematches the background (a dark background must be.dark). - ✓
button_primary_fgonbutton_primary_bgandstatus_fgondangerreach at least 4.5:1, the same check the built-in presets are unit-tested against. - ✓
borderstays distinguishable frombg_primary(the preset tests require ≥ 1.2). - ✓
Switch the Storybook to your theme and click through the common components (the Component Wall story is a good start).