Skip to content

Guide Theming

MeowLynxSea edited this page Jun 18, 2026 · 9 revisions

Theming

The theme is a JSON-backed pub struct Theme(pub serde_json::Value). There is no Rust schema — renderers read paths like action.primary.bg via cx.theme().get_color(...), and your code can do the same.

The two bundled system themes (system-light.json / system-dark.json) ship inside crates/yororen-ui-default-renderer/themes/. The brutalism renderer ships its own themes in crates/yororen-ui-brutalism-renderer/themes/.

How it works

  • The active palette lives in cx.global::<GlobalTheme>(), accessed via the ActiveTheme trait.
  • cx.theme() works on both App and Context<'_, T> — any render closure can call it.
  • The renderer reads the same paths. A missing key returns None; the renderer falls back to its own default.

The default install

yororen_ui::renderer::install(cx, cx.window_appearance());

One call: picks system-light.json or system-dark.json by OS appearance, installs the global Theme, and registers the 55 default XxxRenderer impls.

A custom theme

use yororen_ui_default_renderer::{Theme, install_with};

const MY_THEME: &str = include_str!("../themes/my-brand.json");

fn init(cx: &mut gpui::App) {
    let theme = Theme::from_json(MY_THEME).expect("valid JSON");
    install_with(cx, theme);
}

Missing keys fall back to renderer defaults, so you can ship a theme that overrides only the colors that matter to your brand and inherit the rest.

Light + dark variants

use yororen_ui_default_renderer::{Theme, install_with, system_light, system_dark};

fn init(cx: &mut gpui::App) {
    let theme = match cx.window_appearance() {
        gpui::WindowAppearance::Dark | gpui::WindowAppearance::VibrantDark => system_dark(),
        _ => system_light(),
    };
    install_with(cx, theme);
}

JSON shape

crates/yororen-ui-default-renderer/themes/system-light.json is the canonical starting point:

{
  "surface":   { "base": "#FFFFFF", "canvas": "#F4F4F6", "raised": "#FBFBFD",
                 "sunken": "#EFEFF2", "hover": "#E6E6EA", "popover": "#EFEFF2" },
  "content":   { "primary": "#141416", "secondary": "#3E3E45",
                 "tertiary": "#6B6B73", "disabled": "#9A9AA2",
                 "on_primary": "#FFFFFF", "on_status": "#0B0B0D" },
  "border":    { "default": "#D8D8DD", "muted": "#E3E3E8",
                 "focus": "#2F63FF", "divider": "#E3E3E8" },
  "action": {
    "neutral": { "bg": "#F1F1F3", "hover_bg": "#E6E6EA", "active_bg": "#DADADF",
                 "fg": "#141416", "disabled_bg": "#E7E7EA", "disabled_fg": "#9A9AA2" },
    "primary": { "bg": "#121214", "hover_bg": "#2A2A2E", "active_bg": "#404045",
                 "fg": "#FFFFFF", "disabled_bg": "#2A2A2E", "disabled_fg": "#D0D0D6" },
    "danger":  { "bg": "#FFB4AE", "hover_bg": "#FFA099", "active_bg": "#FF8A82",
                 "fg": "#0B0B0D", "disabled_bg": "#F0CBC7", "disabled_fg": "#9A9AA2" }
  },
  "status": {
    "neutral": { "bg": "#E7E7EA", "fg": "#0B0B0D" },
    "success": { "bg": "#B9F5C9", "fg": "#0B0B0D" },
    "warning": { "bg": "#FFE1A6", "fg": "#0B0B0D" },
    "danger":  { "bg": "#FFB4AE", "fg": "#0B0B0D" },
    "error":   { "bg": "#FFB4AE", "fg": "#0B0B0D" },
    "info":    { "bg": "#B6D9FF", "fg": "#0B0B0D" }
  },
  "shadow": { "elevation_1": "rgba(0,0,0,0.18)", "elevation_2": "rgba(0,0,0,0.30)" },
  "tokens": {
    "sizes":      { "control_h_md": 32, "control_h_lg": 36, /**/ },
    "radii":      { "none": 0, "xs": 2, "sm": 4, "md": 6, "lg": 8, "xl": 12, "pill": 9999 },
    "spacing":    { "gap_1": 4, "gap_2": 8, "gap_3": 12, /**/ },
    "typography": { "font_size_md": 14, "font_size_lg": 16, /**/ },
    "motion":     { "duration_fast": 150, "duration_normal": 200, /**/ },
    "control":    {
      "button":  { "min_height": 36, "horizontal_padding": 16, "radius": 6 },
      "input":   { "min_height": 32, "horizontal_padding": 12, "radius": 4 },
      "modal":   { "min_width": 320, "max_width": 520, "padding": 24, "border_radius": 12 },
      "switch":  { "track_w": 34, "track_h": 18, "knob_size": 14 }
      // one sub-object per component
    }
  }
}

Common paths

Path What Used by
surface.base default card / input background Card, TextInput, Panel
surface.canvas window background root
surface.raised elevated surface Popover, Modal panel
surface.popover popover panel background Popover, DropdownMenu
content.primary primary text Text, Label, Heading
content.tertiary placeholder / hint TextInput, SearchInput
content.on_primary text on action.primary.bg Primary Button
border.focus focus ring every focusable primitive
action.<variant>.bg / .hover_bg / .active_bg / .fg button palette Button / IconButton / ToggleButton
status.<kind>.bg / .fg status colors Badge, Toast, Notification
tokens.control.<component>.<field> per-component geometry every component
tokens.motion.duration_fast (150) / .duration_normal (200) motion timing Tooltip, Menu open, transitions

Theme contract (the keys renderers read)

When you ship a custom theme, the renderers need at least:

surfacebase, canvas, raised, sunken, hover, popover

contentprimary, secondary, tertiary, disabled, on_primary, on_status

borderdefault, muted, focus, divider

action.{neutral, primary, danger} — each with bg, hover_bg, active_bg, fg, disabled_bg, disabled_fg

status.{success, warning, danger, error, info, neutral} — each with bg, fg

shadowelevation_1, elevation_2

tokens.sizes, tokens.radii, tokens.spacing, tokens.typography, tokens.motion, tokens.control.<component> — one sub-object per component. The system-light.json file has every key; ship it as your starting point.

Reading the theme from app code

use yororen_ui::theme::ActiveTheme;

let surface = cx.theme().get_color("surface.base").unwrap_or_default();
let pad     = cx.theme().get_number("tokens.control.button.horizontal_padding").unwrap_or(16.0);

get_color parses hex (#RRGGBB / #RRGGBBAA), {h, s, l, a} objects, and [h, s, l, a] arrays. get_number reads f64 JSON values. Both return Option, so missing keys are safe.

Live theme switching

For a "Next theme" toolbar button, call yororen_ui::theme::install(cx, theme) inside the render closure — every frame, or when the user picks a new palette. Idempotent and cheap.

impl Render for ThemeApp {
    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        yororen_ui::theme::install(cx, self.current_theme());
        // … layout using cx.theme() …
    }
}

The theme_showcase demo does exactly this with four themes.

The brutalism renderer

A completely different visual (sharp corners, hard shadows, monospace, high contrast). Swap renderers:

use yororen_ui::brutalism_renderer;

brutalism_renderer::install(cx);                          // light/dark by appearance
brutalism_renderer::install_with_default_theme(cx);       // force light
brutalism_renderer::install_with(cx, my_brutalism_theme); // custom brutalism JSON

The same headless::button("save", cx).on_click(...).render(cx) call works in all three renderers. The visual is what changes.

See also

Clone this wiki locally