docs/guide/theming
Concepts clés · Thèmes

Thèmes personnalisés

Un thème est une seule valeur ThemeTokens : couleurs, espacements, rayons, tailles de texte et dimensions des contrôles. Copiez un preset intégré, modifiez les champs qui portent votre marque, installez-le avec cx.setTheme, et c'est votre thème.

8 min de lecture · captures d'une vraie fenêtre

Cette page explique comment définir et installer un thème. La façon dont les vues consomment les tokens — fonctions de style et recipes — est traitée dans Styles et thèmes.

Le thème par défaut

Le Cx de chaque fenêtre démarre sur ui.theme.dark. Une app qui n'appelle jamais setTheme est sombre — c'est le cas de Hello Button. Pour utiliser un autre thème, définissez-le à la première ligne de votre fonction de montage, avant qu'aucun nœud n'existe :

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

Avec plusieurs fenêtres, chacune a son propre Cx : définissez donc le thème dans chaque fonction de montage. zenit ne suit pas encore l'apparence du système : un changement clair/sombre du système ne déclenche qu'un redessin, et aucune API publique ne permet de lire l'apparence du système. Proposez plutôt un interrupteur à vos utilisateurs (voir « Changer à l'exécution »).

Anatomie de ThemeTokens

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

Tout sauf color a des valeurs par défaut. Les 54 couleurs de ColorTokens n'en ont aucune : n'écrivez donc pas un littéral de struct à partir de zéro — copiez ui.theme.light, ui.theme.dark ou ui.theme.high_contrast et ne modifiez que ce qui vous intéresse.

Dériver un thème de marque

Écrivez les thèmes comme des const au niveau du conteneur ; le bloc d'initialisation s'exécute à la compilation et le résultat a un stockage statique, donc le pointeur que conserve cx.setTheme(&brand) reste valide pendant toute la vie du programme. Ne prenez jamais l'adresse d'un thème stocké dans une variable locale de fonction.

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

Un thème sombre assorti

Dérivez la variante sombre de ui.theme.dark plutôt que de recolorer le thème de marque clair : les dizaines de valeurs de fond, de texte et de bordure du preset sombre sont réglées ensemble. Sur fond sombre, la couleur de marque doit généralement être éclaircie, et le texte des boutons primary devient sombre.

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 (clair)brand (clair)
brand_darkbrand_dark
ui.theme.dark (par défaut)ui.theme.dark (par défaut)
Le même Hello Button, seul l'argument de setTheme change — les trois captures proviennent de la vraie fenêtre, prises par le harness E2E.

Quels tokens pilotent quoi

Regroupés selon ce que lisent réellement les composants (sources v0.1.0-alpha). Commencez par ceux-ci pour créer un thème :

TokenPrincipalement utilisé par
fg_primary · fg_secondary · fg_tertiary · fg_disabledPresque tous les textes et icônes
bg_primary · bg_secondary · bg_tertiaryPanneaux, cartes, Calendar, Chip, pistes de Progress / Slider, Skeleton
bg_hover · bg_activeÉtats survol et pressé des contrôles cliquables
accentCheckbox / Switch cochés, liens, Tabs, Breadcrumb, dates sélectionnées, Badge
button_primary_bg · button_primary_fgButton primary (hover / pressé dérivés du fond) ; fg sert aussi au texte inversé sur Badge, Chip et Tabs
border · border_strong · separatorBordures et séparateurs
border_focus · input_bg · input_border · selection_bgInput, Select, NumberStepper, DatePicker et autres champs
success · warning · danger · info · status_fgAlert, Badge, Tag, Progress, Button danger
list_hover_bg · list_selection_bgMenu, DropdownMenu, ComboBox, Table, Tree, DataTable
checkbox_* · switch_thumb · switch_track_offCheckbox / Switch non cochés et désactivés
tooltip_bg · tooltip_fg · overlay · scrollbar_thumbTooltip, voile de Modal / Sheet, barres de défilement

Quelques composants ont leurs propres couleurs au lieu de lire ColorTokens : le Notifier choisit entre ses palettes claire et sombre intégrées selon scheme, et les teintes de GlassBox ainsi que certains détails de Progress et Select sont des valeurs fixes. Ils suivent le mode clair/sombre, mais pas votre couleur de marque.

Rayons, espacements et dimensions des contrôles

Les tokens hors couleur peuvent être surchargés champ par champ. Le plus utile est control : Button, Input, Select, ComboBox, DatePicker, DateRangePicker, Tabs et Chip partagent quatre jeux de dimensions, xs / sm / md / lg. La hauteur d'un contrôle n'est pas un token — elle est calculée :

TailleHauteur par défaut = 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

Pour rendre les contrôles md plus hauts, modifiez donc t.control.md.padding_y ou la taille de police, et tous les contrôles md suivent ; ne codez pas en dur la height d'un contrôle. Gardez icon_size inférieur ou égal à la hauteur de ligne.

Changer à l'exécution

cx.setTheme peut être appelé à tout moment : il remplace le pointeur de tokens, rejoue les hooks on_theme / before_render sur tout le sous-arbre cx.root (portal d'overlay compris) et demande un redessin.

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;

Avant de livrer

  • ✓

    scheme correspond au fond (un fond sombre doit être .dark).

  • ✓

    button_primary_fg sur button_primary_bg et status_fg sur danger atteignent au moins 4.5:1 — le même contrôle que les tests unitaires des presets intégrés.

  • ✓

    border reste distinguable de bg_primary (les tests des presets exigent ≥ 1.2).

  • ✓

    Passez le Storybook sur votre thème et parcourez les composants courants (la story Component Wall est un bon point de départ).

zenit · Double licenceGratuit pour les projets open source sous GPL-3.0-only ; les produits propriétaires ou commerciaux nécessitent une licence commerciale.Contacter l’auteur : zongyi.xzy#gmail.com (remplacez # par @)zenit 5f9add5+wip 2026-09-30