---
title: "Styling & themes | zenit Zig UI Docs"
description: "Use tokens, named style functions and recipes so visuals and business logic stay independent."
url: https://ui.surf/zenit/docs/guide/styling
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/guide/styling.md
alternate_es: https://ui.surf/zenit/es/docs/guide/styling.md
alternate_ja: https://ui.surf/zenit/ja/docs/guide/styling.md
alternate_ko: https://ui.surf/zenit/ko/docs/guide/styling.md
alternate_fr: https://ui.surf/zenit/fr/docs/guide/styling.md
alternate_de: https://ui.surf/zenit/de/docs/guide/styling.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# Styling & themes

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

## The three-layer model

**01 · ThemeTokens**

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

**02 · styles.zig**

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

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

[Video](https://ui.surf/zenit/media/stories/button.mp4?v=6203a01415)

Button’s variant × size, icon, loading, disabled and block matrix, styled through a recipe.

[Video](https://ui.surf/zenit/media/stories/glassbox.mp4?v=570227ca3d)

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

> WARNING
> 
> **Avoid bare literals.** `Color.hex(...)` and fixed `font_size` values in views bypass the theme. `scripts/check_style_literals.sh` flags bare literals and lets `ui.arb` through, so grepping for `ui.arb` inventories every off-token value. Once the same arb value shows up three or more times, consider promoting it to a token.

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

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

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

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

> NOTE
> 
> **Replay covers the cx.root subtree.** Component overlays (Modal, Popover, Menu, Tooltip) live in the `WindowOverlayPortal` under `cx.root`, so `setTheme` replays them too; only node trees detached from `cx.root` are out of reach. What gets replayed are the hooks styled nodes register. Most `ui.widgets.*` snapshot tokens at mount and still need a rebuild.

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

## 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 member | Role |
| --- | --- |
| `Variants` | Required. Each field is a variant dimension (enum or bool) with a default |
| `base(t)` | Optional. The base ConditionalStyle |
| `variants` | Optional. 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)) |
| `compounds` | Optional. { 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.

`examples/hello_button/styles.zig`

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

`resolve.zig`

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

| Situation | Tool |
| --- | --- |
| One static style | Named style function |
| Size / visual variants | `ui.recipe.recipe` |
| Multi-part components (inputs, menus) | `ui.recipe.slotRecipe` |
| hover / disabled / invalid | `ui.ConditionalStyle` |
| Light / dark | A 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](https://ui.surf/zenit/components) for every component’s variant and size matrix.
