---
title: "Custom themes | zenit Zig UI Docs"
description: "A theme is a single ThemeTokens value: colors, spacing, radii, type sizes and control metrics."
url: https://ui.surf/zenit/docs/guide/theming
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/guide/theming.md
alternate_es: https://ui.surf/zenit/es/docs/guide/theming.md
alternate_ja: https://ui.surf/zenit/ja/docs/guide/theming.md
alternate_ko: https://ui.surf/zenit/ko/docs/guide/theming.md
alternate_fr: https://ui.surf/zenit/fr/docs/guide/theming.md
alternate_de: https://ui.surf/zenit/de/docs/guide/theming.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# 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](https://ui.surf/zenit/docs/guide/styling).

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

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

```zig
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.

> NOTE
> 
> **Use scheme to tell light from dark.** `scheme` is the only light/dark flag: the Notifier picks its palette from it and DevTools derives its colors from it. Never sniff `name` for “dark”: the high-contrast theme is pure black and its name says nothing of the sort.

## Deriving a brand theme

Write themes as container-level `const`s; 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`

```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;
};
```

> WARNING
> 
> **Component tokens do not follow accent.** In the presets, `accent` and `button_primary_bg` are independent values. Nothing is derived. When you change the brand color, change `accent`, `button_primary_bg` / `button_primary_fg` and `border_focus` together, or primary buttons keep the preset color.

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

```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)](https://ui.surf/zenit/media/theme-brand.webp?v=588f003545)brand (light)

![brand_dark](https://ui.surf/zenit/media/theme-brand-dark.webp?v=854df7b29f)brand\_dark

![ui.theme.dark (default)](https://ui.surf/zenit/media/theme-default-dark.webp?v=3b5b953dc1)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:

| Token | Mainly used by |
| --- | --- |
| `fg_primary · fg_secondary · fg_tertiary · fg_disabled` | Nearly all text and icons |
| `bg_primary · bg_secondary · bg_tertiary` | Panels, cards, Calendar, Chip, Progress / Slider tracks, Skeleton |
| `bg_hover · bg_active` | Hover and pressed states of clickable controls |
| `accent` | Checked Checkbox / Switch, links, Tabs, Breadcrumb, selected dates, Badge |
| `button_primary_bg · button_primary_fg` | Primary Button (hover / pressed derive from the background); fg also inverts text on Badge, Chip and Tabs |
| `border · border_strong · separator` | Borders and dividers |
| `border_focus · input_bg · input_border · selection_bg` | Input, Select, NumberStepper, DatePicker and other fields |
| `success · warning · danger · info · status_fg` | Alert, Badge, Tag, Progress, danger Button |
| `list_hover_bg · list_selection_bg` | Menu, DropdownMenu, ComboBox, Table, Tree, DataTable |
| `checkbox_* · switch_thumb · switch_track_off` | Unchecked and disabled Checkbox / Switch |
| `tooltip_bg · tooltip_fg · overlay · scrollbar_thumb` | Tooltip, Modal / Sheet scrim, scrollbars |

> WARNING
> 
> **No component reads these yet.** `accent_muted`, `bg_inset`, `button_secondary_bg`, `button_secondary_fg`, `switch_track_on` (an on Switch uses `accent`), `table_header_bg`, `list_selection_hover_bg`, and `success_subtle` / `warning_subtle` / `danger_subtle` / `info_subtle`. Changing them has no visible effect today; your own style functions can still use them.

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:

| Size | Default height = padding\_y × 2 + font\_size × line\_height |
| --- | --- |
| `xs` | 2.5 × 2 + 12 × 1.25 = 20 |
| `sm` | 4.5 × 2 + 12 × 1.25 = 24 |
| `md` | 7.25 × 2 + 14 × 1.25 = 32 |
| `lg` | 10 × 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`

```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),
```

```zig
// 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;
```

> NOTE
> 
> **What a runtime switch reaches.** Nodes built with `boxStyled` / `textStyled` and components that register theme hooks recolor immediately; most `ui.widgets.*` read tokens at mount, so rebuild those subtrees for a complete switch. Apps that pick their theme once at startup are unaffected.

## 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).
