Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Theming

Kael has one theming pipeline with two cooperating types, each with a clear role:

  • kael::Theme — the serializable, file-facing theme. It is what a JSON/TOML theme file deserializes into (colors, typography, spacing, radii, shadows), and it is what the hot-reload file watcher reloads. Think of it as the theme on disk.
  • kael_ui::Theme (a variant plus a rich [ThemeTokens]) — the runtime token system that components actually render from. Every kael_ui component reads Theme::of(cx).tokens.*. Think of it as the theme in memory.

A bridge connects them so that editing a theme file restyles live components.

The pipeline

  theme.toml / theme.json          (you edit this)
        │  file watcher (App::observe_theme_file)
        ▼
  kael::Theme                      (parsed, stored as a Global)
        │  App::observe_theme_files subscriber
        ▼
  kael_ui::ThemeTokens             (core fields mapped onto current tokens)
        │  install_theme  →  set_global + refresh_windows
        ▼
  components                       (re-render with Theme::of(cx).tokens.*)

Each stage is one observable hop: a file edit walks all the way down to a visible restyle, with no restart.

Reading the theme in components

Components read tokens through the zero-clone borrow:

#![allow(unused)]
fn main() {
impl Render for MyView {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let theme = Theme::of(cx);

        div()
            .bg(theme.tokens.background)
            .text_color(theme.tokens.foreground)
            .border_color(theme.tokens.border)
            .child(
                div()
                    .bg(theme.tokens.primary)
                    .text_color(theme.tokens.primary_foreground)
                    .px(px(16.0))
                    .py(px(8.0))
                    .rounded(theme.tokens.radius_md)
                    .child("Primary button"),
            )
    }
}
}

Theme::of(cx) (alias Theme::get(cx)) borrows the theme from the app's global state without cloning. use_theme() is a legacy clone-per-call shim retained for call sites where the borrow checker cannot take a &Theme cleanly; prefer Theme::of(cx) everywhere else.

Presets, custom themes, and live switching

kael_ui ships 18 presets and lets you brand your app from any of them:

#![allow(unused)]
fn main() {
kael_ui::init(cx);
install_theme(cx, Theme::dark());

// Brand it: start from a preset's tokens and override what you need.
let brand = Theme::custom(ThemeTokens {
    primary: hsla(262.0 / 360.0, 0.83, 0.58, 1.0),
    radius_md: px(10.0),
    ..ThemeTokens::dark()
});
install_theme(cx, brand);
}

install_theme stores the active Theme as a Global and refreshes every open window, so re-installing at runtime switches themes live. See Component Library.

Theme files

A theme file deserializes into a kael::Theme. Fields are grouped; omit any section to keep its defaults.

[colors]
background = "#0b1020"
surface    = "#161c2e"
primary    = "#6366f1"
accent     = "#22d3ee"
muted      = "#3b4252"
foreground = "#e5e7eb"
border     = "#2a3350"
error      = "#ef4444"

[radii]
sm = 4.0
md = 8.0
lg = 12.0
xl = 16.0

[typography]
ui_font_family   = "Inter"
code_font_family = "JetBrains Mono"

The same shape works as JSON. Load one directly with:

#![allow(unused)]
fn main() {
let theme = kael::Theme::from_path("themes/active.toml")?;
cx.set_global(theme);
}

Hot-reload end-to-end

Wire the file watcher and the bridge once during startup. After that, every save to the watched file restyles the live UI:

#![allow(unused)]
fn main() {
Application::new().run(move |cx| {
    kael_ui::init(cx);
    install_theme(cx, Theme::dark());

    // Register the bridge: maps reloaded kael::Theme -> ThemeTokens, then
    // install_theme (refreshing all windows).
    install_theme_file_bridge(cx);

    // Watch the file; on_change updates the core kael::Theme global, which
    // fires the bridge subscriber registered above.
    cx.observe_theme_file("themes/active.toml", |theme, cx| cx.set_global(theme))
        .expect("failed to watch theme file");

    // ... open your window; components read Theme::of(cx).tokens.*
});
}

Order matters only in that the bridge must be registered before (or alongside) the watcher; observe_theme_file applies the initial file once, and every later save flows through the same path. The Astryx showcase demonstrates live theme switching alongside branded components.

Core → token mapping

When a theme file reloads, the bridge maps the loaded kael::Theme onto the currently installed ThemeTokens. Token fields without a core source are preserved, so a partial file changes only what it names.

core kael::Theme fieldThemeTokens field(s)
colors.backgroundbackground
colors.foregroundforeground
colors.surfacecard, popover
colors.primaryprimary, ring
colors.accentaccent
colors.mutedmuted
colors.borderborder, input
colors.errordestructive
radii.sm / md / lg / xlradius_sm/md/lg/xl
shadows.sm / md / lgshadow_sm/md/lg
typography.ui_font_familyfont_family
typography.code_font_familyfont_mono

Fields with no core source keep their existing token values: the *_foreground colors, secondary, muted_foreground, accent_foreground, shadow_xs, shadow_xl, ring_offset, and the spacing / duration / z-index scales. Core fields with no token target (separator, selected_text, warning, success, radii.pill, and the typographic sizes/weights) are intentionally not mapped.

If you need a different mapping, call tokens_from_core_theme(core, base) yourself inside a custom cx.observe_theme_files subscriber.