System Themes

Overview

WIP. Discovery (theme, accent, fonts, accessibility) is wired up across all desktop platforms. The user-facing CSS hooks (system:* colors, system:* fonts, @theme dark) work today. Some discovered values still arrive via CLI wrappers; the FFI-direct paths and ricing overrides are still being stabilized.

A native-feeling app reads its colors and fonts from the host OS. Azul exposes those values through three CSS hooks:

  • system:<color> for colors that follow the user's accent and theme.
  • system:<font> for the platform's UI, monospace, or serif fonts.
  • @theme dark { ... } and @os <name> for conditional rules that re-evaluate per frame when the user toggles dark mode or moves to a different desktop environment.
background: system:window-background;
color: system:text;
font-family: system:ui;
border: 1px solid system:accent;
@theme dark {
    background: #1c1c1e;
    color: #f0f0f0;
}
Light theme
Dark theme, OS-supplied palette
## System colors

Use a system:<name> keyword wherever a color is accepted. The framework resolves it at frame time using the user's current settings:

  • system:text. Primary text color.
  • system:background. Content background.
  • system:accent. The user's accent color (Windows, macOS, GNOME).
  • system:accent-text. Readable text on an accent fill.
  • system:button-face. Button or control background.
  • system:button-text. Button or control text.
  • system:window-background. Window chrome background.
  • system:selection-background. Selected-text background.
  • system:selection-text. Selected-text foreground.

The resolver picks the user's current value if the OS reported one, and falls back to a standard color otherwise. The full collection (link, separator, grid, sidebar, and inactive-window variants) is on SystemColors.

use azul::prelude::*;
let _ = Dom::create_button("Save", SmallAriaInfo::label("Save")).with_css("
    background: system:button-face;
    color: system:button-text;
    border: 1px solid system:accent;
    padding: 6px 14px;
    :hover { background: system:accent; color: system:accent-text; }
");

System fonts

system:<role> keywords pick the right face on each platform. SystemFontType enumerates the roles:

  • system:ui. macOS: SF Pro Text. Windows: Segoe UI Variable. Linux: Cantarell.
  • system:ui:bold. macOS: SF Pro Text Bold. Windows: Segoe UI Bold. Linux: Cantarell Bold.
  • system:monospace. macOS: SF Mono or Menlo. Windows: Cascadia Mono or Consolas. Linux: Ubuntu Mono or DejaVu Sans Mono.
  • system:monospace:bold. macOS: Menlo Bold. Windows: Cascadia Mono Bold. Linux: Ubuntu Mono Bold.
  • system:monospace:italic. macOS: Menlo Italic. Windows: Cascadia Mono Italic. Linux: Ubuntu Mono Italic.
  • system:title. macOS: SF Pro Display. Windows: Segoe UI Variable Display. Linux: Cantarell.
  • system:title:bold. macOS: SF Pro Display Bold. Windows: Segoe UI Variable Display Bold. Linux: Cantarell Bold.
  • system:menu. macOS: SF Pro Text. Windows: Segoe UI. Linux: Cantarell.
  • system:small. macOS: SF Pro Text 11pt. Windows: Segoe UI 9pt. Linux: Cantarell 9pt.
  • system:serif. macOS: New York. Windows: Cambria. Linux: DejaVu Serif.
  • system:serif:bold. macOS: Georgia Bold. Windows: Cambria Bold. Linux: DejaVu Serif Bold.

The framework walks a fallback chain at font resolution and falls through to the sans-serif, monospace, or serif generics if none match.

font-family: system:ui;
font-size: 14px;

@theme adaptation

@theme <variant> { ... } blocks evaluate per frame. The variant matches the system's current preference (light or dark) and updates the moment the user toggles their OS-wide setting (no DOM rebuild required):

background: white;
color: #1a1a1a;
@theme dark {
    background: #1c1c1e;
    color: #f0f0f0;
}
@theme custom-high-contrast {
    background: black;
    color: yellow;
}

The variants follow ThemeCondition:

  • @theme light: system reports light theme.
  • @theme dark: system reports dark theme.
  • @theme <name>: a custom string treated as user-defined. The system resolver doesn't emit it on its own.

For typical apps, define the base style for light mode and override selected properties under @theme dark. Combine with @os for platform-flavoured dark mode (a Mac-style sheen vs. a Windows-style flat fill).

@os

A single rule covers OS family, version, and Linux desktop environment. The grammar is @os(<family>[:<de>] [<op> <version>]). Each clause is optional; op is >=, <=, or =.

OS families:

  • windows. Windows desktop.
  • macos. macOS.
  • ios. iOS.
  • apple. macOS or iOS.
  • linux. Any Linux desktop.
  • android. Android.
  • web. WASM target.
  • any. Always matches.

Family-only rules also accept the bare-identifier form: @os linux { … } is the same as @os(linux) { … }.

/* family only */
@os(linux)               { font-family: 'Cantarell'; }
@os(windows)             { font-family: 'Segoe UI'; }

/* family + version — codename or bare number both work */
@os(windows >= 11)       { font-family: 'Segoe UI Variable Text'; }
@os(macos >= big-sur)    { font-family: '.SF NS'; }
@os(linux >= 6)          { /* kernel 6.0+ */ }

/* Linux desktop environment */
@os(linux:gnome)         { font-family: 'Cantarell'; }
@os(linux:kde)           { font-family: 'Noto Sans'; }

/* family + DE + DE version */
@os(linux:gnome > 40)    { padding-inline-start: 16px; }

Comparisons across OS families always evaluate to false. @os(macos >= sonoma) on Windows is just inert, not a parse error.

Desktop-environment versions only match when the runtime knows the DE's version number; until detection is wired up for a given DE, the @os(linux:de > N) form will not match.

Version synonyms are accepted permissively: bare numbers (11), prefixed forms (win-11, win11, windows-11), and codenames where they exist (big-sur, monterey, sonoma) all map to the same underlying version. Linux accepts 5, 5.4, and 5.4.10.

Accessibility queries

These map to the OS's accessibility settings. They live on AccessibilitySettings and re-evaluate per frame.

transition: background 200ms ease;
@media (prefers-reduced-motion) {
    transition: none;
}
@media (prefers-contrast) {
    background: black;
    color: white;
    border: 2px solid white;
}
  • @media (prefers-reduced-motion). Source: macOS AXReduceMotion, Windows SPI_GETCLIENTAREAANIMATION, Linux enable-animations.
  • @media (prefers-contrast). Source: macOS AXIncreaseContrast, Windows SPI_GETHIGHCONTRAST, Linux high-contrast.

Honour prefers-reduced-motion for any non-essential animation.

@media viewport queries

Standard CSS:

padding: 24px;
font-size: 16px;
@media (max-width: 640px) {
    padding: 12px;
    font-size: 14px;
}
@media (orientation: portrait) {
    flex-direction: column;
}

The viewport size comes from the current window. On a multi-window app each window has its own viewport, evaluated independently.

@lang()

Match the system locale. Prefix matching: @lang(de) matches de, de-DE, de-AT. Useful for locale-specific quotes, hyphenation, and typographic conventions:

quotes: '\u{201C}' '\u{201D}';
@lang(de) { quotes: '\u{201E}' '\u{201C}'; }
@lang(fr) { quotes: '\u{00AB} ' ' \u{00BB}'; }

The active locale field is SystemStyle.language (BCP 47, e.g., "en-US").

Reading discovered values from Rust

The full snapshot is SystemStyle. Every field ends up populating the dynamic selectors and the system:* resolver. In Rust code you generally don't need to touch it. Stick with system:* and @theme or @os in your CSS. Those expressions stay ergonomic and re-evaluate automatically.

How user theming layers with component CSS

The cascade has three layers, from outermost to innermost:

  1. System discovery (the system:* keywords and @theme dark condition). Resolved per frame from the running OS, so a theme toggle takes effect on the next paint without a re-layout.
  2. End-user ricing — the optional CSS file the user dropped into ~/.config/azul/styles/<app>.css (Linux/macOS) or %APPDATA%\azul\styles\<app>.css (Windows). Loaded at app startup when the io feature is on.
  3. Application CSS — every component-level Css attached via Dom::style(...) on a subtree root, plus inline rules attached via Dom::with_css(...) on individual nodes. CSS lives on the tree the layout callback returns; there is no global stylesheet passed to App::create.

Components don't fight user theming because their selectors target component-internal classes (.shadcn-card, .my-row) while user theming targets the system:* color and font hooks. As long as a component reads its colors from system:* instead of hard-coding hex values, the user's ~/.config/azul/styles/<app>.css can repaint the component without the component's source changing.

A few escape hatches when the discovery isn't enough:

  • Inline override on a node: Dom::with_css_property(...) wins the cascade for that node.
  • Subtree override via component CSS: stack a second Css via Dom::style(css). Later rule blocks win at equal (priority, specificity). See DOM › Component-level stylesheets.

Controlling end-user customization

Azul has a single env var for the entire end-user-customization layer: AZ_RICING.

Unset (the default) means the framework loads the user CSS file at ~/.config/azul/styles/<app>.css if it exists, and on Linux runs the standard detection chain (KDE > GNOME > riced-desktop > defaults). This is the right behavior for a normal install on a normal desktop.

AZ_RICING=off (aliases: disabled, none, 0) skips both the user CSS file and the riced-desktop sources. Pick this for a kiosk build, a CI runner, or any install that must not pick up local theme customization. The cascade still runs system:* resolution and @theme conditions — disabling ricing only stops the user-supplied layer; the OS-supplied palette is still honored.

AZ_RICING=force (aliases: prefer, aggressive, 1) reorders the Linux detection chain so riced-desktop sources (Hyprland config, pywal cache, i3/sway) win over the GNOME and KDE paths. Use this when XDG_CURRENT_DESKTOP still reports gnome but the actual session is a tiling WM with a custom palette. The user CSS file still loads in this mode.

There is no env var that forces dark/light mode. The platform's own facilities (macOS General > Appearance, Windows Personalization > Colors > Choose your mode, GNOME Settings > Appearance) drive the prefers-color-scheme discovery and azul re-evaluates @theme on the next frame.

Previewing on a different platform

Themes vary not just by light/dark but by OS conventions: a button on iOS doesn't look like a button on Windows 11, and system:* colors resolve differently on each. The main() function can override the detected environment between AppConfig::create() and App::create(), forcing the cascade to evaluate as if the app were running on a different platform:

use azul::prelude::*;

fn main() {
    let mut config = AppConfig::create();

    // Pretend this app is running on iOS, dark theme, with a French
    // locale, on a 360x780 viewport. Useful for screenshot diffing,
    // designer review, or "does my app look OK on Android?" checks.
    config.mock_css_environment = OptionCssMockEnvironment::Some(
        CssMockEnvironment {
            os: OptionOsCondition::Some(OsCondition::Ios),
            theme: OptionThemeCondition::Some(ThemeCondition::Dark),
            language: OptionString::Some("fr-FR".into()),
            viewport_width: OptionF32::Some(360.0),
            viewport_height: OptionF32::Some(780.0),
            ..Default::default()
        }
    );

    // Or swap the whole SystemStyle for a curated platform preset:
    // config.system_style = SystemStyle::ios_light();

    let app = App::create(initial_data, config);
    app.run(WindowCreateOptions::new(layout));
}

Every field on CssMockEnvironment is optional — the ones not set fall back to auto-detected values. Combined with the SystemStyle::android_material_light(), SystemStyle::windows_xp_luna(), and other curated presets in css::system::defaults, this gives a single binary the ability to render its UI as if it were running on any supported target — useful for screenshot testing, designer review, and „what would my app look like, pixel by pixel, on iOS?“ sanity checks.

The override is read at app startup; it doesn't change the actual runtime windowing backend (AZ_BACKEND does that, and that env var isn't a theming concern — it picks x11 vs wayland etc. for the real OS the app is actually running on). For the full list of AZ_* runtime env vars (debug server, profiling, layout tracing, headless rendering), see Debugging.

Coming Up Next

  • Styling Text — Font family, size, weight, alignment, decoration, and the system font keywords
  • Icon Packs — Register icons and use them with Dom::create_icon or <icon>
  • Accessibility — Screen reader integration and ARIA roles

Back to guide index