docs/guide/theming
Conceptos clave · Temas

Temas personalizados

Un tema es un único valor ThemeTokens: colores, espaciado, radios, tamaños de texto y métricas de controles. Copia un preset incluido, cambia los campos que llevan tu marca, instálalo con cx.setTheme y ya es tu tema.

8 min de lectura · capturas de una ventana real

Esta página trata de definir e instalar un tema. Cómo consumen los tokens las vistas (funciones de estilo y recipes) se explica en Estilos y temas.

El tema por defecto

El Cx de cada ventana empieza en ui.theme.dark. Una app que nunca llama a setTheme es oscura, como Hello Button. Para usar otro tema, establécelo en la primera línea de tu función de montaje, antes de que exista ningún nodo:

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

Con varias ventanas, cada una tiene su propio Cx, así que establece el tema en cada función de montaje. zenit no sigue todavía la apariencia del sistema: un cambio claro/oscuro del sistema solo provoca un redibujado, y no hay una API pública para leer la apariencia del sistema. Ofrece en su lugar un interruptor a tus usuarios (consulta “Cambiar en tiempo de ejecución”).

Anatomía de ThemeTokens

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

Todo salvo color tiene valores por defecto. Los 54 colores de ColorTokens no tienen ninguno, así que no escribas un literal de struct desde cero: copia ui.theme.light, ui.theme.dark o ui.theme.high_contrast y cambia solo lo que te interese.

Derivar un tema de marca

Escribe los temas como const a nivel de contenedor; el bloque inicializador se ejecuta en tiempo de compilación y el resultado tiene almacenamiento estático, de modo que el puntero que guarda cx.setTheme(&brand) sigue siendo válido durante toda la vida del programa. Nunca tomes la dirección de un tema guardado en una variable local de una función.

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 tema oscuro a juego

Deriva la variante oscura de ui.theme.dark en lugar de recolorear el tema de marca claro: las decenas de valores de fondo, texto y borde del preset oscuro están ajustados como conjunto. Sobre fondos oscuros, el color de marca suele necesitar aclararse, y el texto de los botones primary pasa a ser oscuro.

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 (claro)brand (claro)
brand_darkbrand_dark
ui.theme.dark (por defecto)ui.theme.dark (por defecto)
El mismo Hello Button cambiando solo el argumento de setTheme. Las tres capturas las tomó el harness E2E en la ventana real.

Qué tokens afectan a qué

Agrupados según lo que realmente leen los componentes (código fuente de v0.1.0-alpha). Empieza por estos al crear un tema:

TokenUso principal
fg_primary · fg_secondary · fg_tertiary · fg_disabledCasi todo el texto y los iconos
bg_primary · bg_secondary · bg_tertiaryPaneles, tarjetas, Calendar, Chip, pistas de Progress / Slider, Skeleton
bg_hover · bg_activeEstados hover y pulsado de los controles clicables
accentCheckbox / Switch marcados, enlaces, Tabs, Breadcrumb, fechas seleccionadas, Badge
button_primary_bg · button_primary_fgButton primary (hover / pulsado se derivan del fondo); fg también invierte el texto en Badge, Chip y Tabs
border · border_strong · separatorBordes y separadores
border_focus · input_bg · input_border · selection_bgInput, Select, NumberStepper, DatePicker y otros campos
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 sin marcar y deshabilitados
tooltip_bg · tooltip_fg · overlay · scrollbar_thumbTooltip, velo de Modal / Sheet, barras de desplazamiento

Algunos componentes llevan sus propios colores en lugar de leer ColorTokens: el Notifier elige entre sus paletas clara y oscura incluidas según scheme, y los tintes de GlassBox y algunos detalles de Progress y Select son valores fijos. Siguen el modo claro/oscuro, pero no tu color de marca.

Radios, espaciado y métricas de controles

Los tokens que no son de color pueden sobrescribirse campo a campo. El más útil es control: Button, Input, Select, ComboBox, DatePicker, DateRangePicker, Tabs y Chip comparten cuatro conjuntos de métricas, xs / sm / md / lg. La altura de un control no es un token: se calcula:

TamañoAltura por defecto = 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

Así que, para que los controles md sean más altos, cambia t.control.md.padding_y o el tamaño de fuente y todos los controles md lo seguirán; no fijes a mano la height de un control. Mantén icon_size no mayor que la altura de línea.

Cambiar en tiempo de ejecución

cx.setTheme puede llamarse en cualquier momento: cambia el puntero de tokens, vuelve a ejecutar los hooks on_theme / before_render en todo el subárbol de cx.root (incluido el portal de capas flotantes) y solicita un redibujado.

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;

Antes de publicar

  • ✓

    scheme coincide con el fondo (un fondo oscuro debe ser .dark).

  • ✓

    button_primary_fg sobre button_primary_bg y status_fg sobre danger alcanzan al menos 4.5:1, la misma comprobación con la que se prueban los presets incluidos.

  • ✓

    border sigue distinguiéndose de bg_primary (los tests de los presets exigen ≥ 1.2).

  • ✓

    Cambia el Storybook a tu tema y recorre los componentes habituales (la story Component Wall es un buen punto de partida).

zenit · Doble licenciaGratis para proyectos de código abierto bajo GPL-3.0-only; los productos cerrados o comerciales necesitan una licencia comercial.Contacta con el autor: zongyi.xzy#gmail.com (cambia # por @)zenit 4c50245 2026-10-01