docs/guide/theming
Core concepts · Themes

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.

8 min read · screenshots from a real window

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:

main.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;
}

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

src/ui/theme.zig (excerpt)
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
// 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.

theme.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 (light)brand (light)
brand_darkbrand_dark
ui.theme.dark (default)ui.theme.dark (default)
The same Hello Button with only the setTheme argument changed. All three were captured from the real window by the E2E harness.

Which tokens drive what

Grouped by what components actually read (v0.1.0-alpha source). Start with these when you theme:

TokenMainly used by
fg_primary · fg_secondary · fg_tertiary · fg_disabledNearly all text and icons
bg_primary · bg_secondary · bg_tertiaryPanels, cards, Calendar, Chip, Progress / Slider tracks, Skeleton
bg_hover · bg_activeHover and pressed states of clickable controls
accentChecked Checkbox / Switch, links, Tabs, Breadcrumb, selected dates, Badge
button_primary_bg · button_primary_fgPrimary Button (hover / pressed derive from the background); fg also inverts text on Badge, Chip and Tabs
border · border_strong · separatorBorders and dividers
border_focus · input_bg · input_border · selection_bgInput, Select, NumberStepper, DatePicker and other fields
success · warning · danger · info · status_fgAlert, Badge, Tag, Progress, danger Button
list_hover_bg · list_selection_bgMenu, DropdownMenu, ComboBox, Table, Tree, DataTable
checkbox_* · switch_thumb · switch_track_offUnchecked and disabled Checkbox / Switch
tooltip_bg · tooltip_fg · overlay · scrollbar_thumbTooltip, Modal / Sheet scrim, scrollbars

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

SizeDefault height = padding_y × 2 + font_size × line_height
xs2.5 × 2 + 12 × 1.25 = 20
sm4.5 × 2 + 12 × 1.25 = 24
md7.25 × 2 + 14 × 1.25 = 32
lg10 × 2 + 16 × 1.25 = 40

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

prefs.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),
reactive read
// 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

  • ✓

    scheme matches the background (a dark background must be .dark).

  • ✓

    button_primary_fg on button_primary_bg and status_fg on danger reach at least 4.5:1, the same check the built-in presets are unit-tested against.

  • ✓

    border stays distinguishable from bg_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).

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