Part 10 of 14 24 Jan 2026 By Raj Patil 25 min read

Part 10: Theming - Creating Beautiful, Consistent Designs

Part 10 of the Freya Rust GUI series. Learn to create consistent themes with colors, support light/dark modes, and customize component appearances.

Intermediate #rust #freya #gui #theming #dark-mode #design-system #tutorial
Building Native GUIs with Rust & Freya 10 / 14

Theming

A good theme makes your application feel cohesive and professional. In this tutorial, we’ll explore how to create and manage themes in Freya.

[!NOTE] What is Theming? Theming is the process of defining consistent visual properties (colors, spacing, typography) across your application. A good theme ensures every component looks like it belongs together.


Built-in Themes

Freya includes a powerful built-in theming system with pre-defined light and dark themes.

Using Built-in Themes

Set the theme for your entire application using use_init_root_theme:

use freya::prelude::*;

fn app() -> impl IntoElement {
    use_init_root_theme(|| DARK_THEME);

    rect()
        .background("surface_tertiary")
        .child(label().text("Hello, World!"))
}

Available Themes

ThemeDescription
LIGHT_THEMELight background with dark text
DARK_THEMEDark background with light text
BASE_THEMEBase theme for creating custom themes

Theme Switching

fn app() -> impl IntoElement {
    let is_dark = use_state(|| false);
    let theme = use_theme();

    rect()
        .child(
            Button::new()
                .on_press(move |_| {
                    is_dark.toggle();
                    *theme.write() = if is_dark() { DARK_THEME } else { LIGHT_THEME };
                })
                .child("Toggle Theme")
        )
}

Color Sheet

Themes define a comprehensive color palette organized into categories. You can reference these colors by name using strings.

Using Theme Colors

rect()
    .background("surface_primary")  // Reference by name
    .color("text_primary")
    .border(Border::new().fill("border"))

Brand Colors

ColorDescription
primaryMain brand color
secondarySecondary brand color
tertiaryTertiary brand color

Status Colors

ColorDescription
successSuccess states (green)
warningWarning states (yellow)
errorError states (red)
infoInfo states (blue)

Surface Colors

ColorDescription
backgroundMain background
surface_primaryPrimary surface
surface_secondarySecondary surface
surface_tertiaryTertiary surface
surface_inverseInverse surface
surface_inverse_secondarySecondary inverse
surface_inverse_tertiaryTertiary inverse

Border Colors

ColorDescription
borderDefault border
border_focusFocused border
border_disabledDisabled border

Text Colors

ColorDescription
text_primaryPrimary text
text_secondarySecondary text
text_placeholderPlaceholder text
text_inverseInverse text
text_highlightHighlighted text

State Colors

ColorDescription
hoverHover state
focusFocus state
activeActive state
disabledDisabled state

Utility Colors

ColorDescription
overlayModal overlays
shadowShadows

Theme Management

Accessing Theme

Read the current theme in any component:

fn themed_component() -> impl IntoElement {
    let theme = use_theme();

    rect()
        .background(theme.read().colors.background)
        .child(label()
            .text("Current theme")
            .color(theme.read().colors.text_primary)
        )
}

Nested Themes

Override the theme for a subtree:

fn app() -> impl IntoElement {
    use_init_root_theme(|| LIGHT_THEME);

    rect()
        .child(label().text("Light theme"))
        .child(
            rect()
                .with(|| use_init_theme(|| DARK_THEME))
                .child(label().text("Dark theme"))
        )
}

Theme Preferences

Themes use a preference system for colors:

// Specific color
Preference::Specific(Color::BLUE)

// Reference to color sheet
Preference::Reference("primary")

This allows themes to reference colors from the color sheet, making it easy to change all components by modifying the color sheet.


Surface Theme Indicator

For components that need to adapt based on their background:

use_init_surface_theme_indicator(|| SurfaceThemeIndicator::Opposite);

// Children can check:
let surface = use_surface_theme_indicator();
match surface {
    SurfaceThemeIndicator::Primary => { /* on primary surface */ }
    SurfaceThemeIndicator::Opposite => { /* on opposite surface */ }
}

Custom Themes

Creating a Custom Theme

Create your own theme by building on BASE_THEME:

const MY_THEME: Theme = Theme {
    name: "my-theme",
    colors: ColorsSheet {
        primary: Color::from_rgb(0, 120, 200),
        secondary: Color::from_rgb(100, 180, 255),
        tertiary: Color::from_rgb(0, 80, 150),
        success: Color::from_rgb(34, 197, 94),
        warning: Color::from_rgb(234, 179, 8),
        error: Color::from_rgb(239, 68, 68),
        info: Color::from_rgb(59, 130, 246),
        background: Color::from_rgb(255, 255, 255),
        surface_primary: Color::from_rgb(249, 250, 251),
        surface_secondary: Color::from_rgb(243, 244, 246),
        surface_tertiary: Color::from_rgb(229, 231, 235),
        text_primary: Color::from_rgb(17, 24, 39),
        text_secondary: Color::from_rgb(107, 114, 128),
        border: Color::from_rgb(229, 231, 235),
        // ... other colors
        ..ColorsSheet::default()
    },
    // Component themes inherit from BASE_THEME
    ..BASE_THEME
};

Using Your Custom Theme

fn app() -> impl IntoElement {
    use_init_root_theme(|| MY_THEME);

    rect()
        .background("background")
        .child(label().text("Custom themed app!"))
}

Custom Design Tokens (Alternative Approach)

Instead of using the built-in theme system, you can also create your own design token system using Freya’s context. This gives you more control but requires more setup.

Design tokens are the building blocks of your theme - named values for colors, spacing, typography, etc.

Defining Color Tokens

struct ColorTokens {
    // Brand colors
    primary: Color,
    secondary: Color,
    accent: Color,

    // Semantic colors
    success: Color,
    warning: Color,
    error: Color,
    info: Color,

    // Neutral colors
    background: Color,
    surface: Color,
    border: Color,
    text: Color,
    text_secondary: Color,
}

Light Theme Tokens

impl Default for ColorTokens {
    fn default() -> Self {
        Self {
            // Brand
            primary: Color::from_rgb(79, 70, 229),      // Indigo
            secondary: Color::from_rgb(107, 114, 128),  // Gray
            accent: Color::from_rgb(236, 72, 153),      // Pink

            // Semantic
            success: Color::from_rgb(34, 197, 94),      // Green
            warning: Color::from_rgb(234, 179, 8),      // Yellow
            error: Color::from_rgb(239, 68, 68),        // Red
            info: Color::from_rgb(59, 130, 246),        // Blue

            // Neutral
            background: Color::from_rgb(255, 255, 255), // White
            surface: Color::from_rgb(249, 250, 251),    // Light gray
            border: Color::from_rgb(229, 231, 235),     // Border gray
            text: Color::from_rgb(17, 24, 39),          // Near black
            text_secondary: Color::from_rgb(107, 114, 128), // Gray
        }
    }
}

Dark Theme Tokens

fn dark_colors() -> ColorTokens {
    ColorTokens {
        // Brand (adjusted for dark mode)
        primary: Color::from_rgb(129, 140, 248),    // Lighter indigo
        secondary: Color::from_rgb(156, 163, 175),  // Lighter gray
        accent: Color::from_rgb(244, 114, 182),     // Lighter pink

        // Semantic (adjusted for dark mode)
        success: Color::from_rgb(74, 222, 128),     // Lighter green
        warning: Color::from_rgb(250, 204, 21),     // Lighter yellow
        error: Color::from_rgb(248, 113, 113),      // Lighter red
        info: Color::from_rgb(96, 165, 250),        // Lighter blue

        // Neutral (inverted)
        background: Color::from_rgb(17, 24, 39),    // Near black
        surface: Color::from_rgb(31, 41, 55),       // Dark gray
        border: Color::from_rgb(55, 65, 81),        // Border dark
        text: Color::from_rgb(249, 250, 251),       // Near white
        text_secondary: Color::from_rgb(156, 163, 175), // Light gray
    }
}

Custom Theme Context

If you prefer more control, you can share a custom theme across your application using context:

Define Theme Type

#[derive(Clone, PartialEq)]
struct Theme {
    colors: ColorTokens,
    mode: ThemeMode,
}

#[derive(Clone, PartialEq, Copy)]
enum ThemeMode {
    Light,
    Dark,
}

Provide Theme at Root

fn app() -> impl IntoElement {
    let theme = use_state(|| Theme::default());

    use_provide_context(|| theme.clone());

    rect()
        .expanded()
        .background(theme.colors.background)
        .color(theme.colors.text)
        .child(NavBar {})
        .child(MainContent {})
}

Consume Theme in Components

fn themed_button() -> impl IntoElement {
    let theme = use_consume::<State<Theme>>();

    rect()
        .background(theme.colors.primary)
        .corner_radius(8.0)
        .padding_horizontal(16.0)
        .padding_vertical(8.0)
        .child(label()
            .text("Themed Button")
            .color(Color::WHITE)
        )
}

Custom Light/Dark Mode

For the custom theme approach, here’s how to implement light/dark mode switching:

Toggle Implementation

fn theme_toggle() -> impl IntoElement {
    let theme = use_consume::<State<Theme>>();

    rect()
        .on_pointer_down(move |_| {
            theme.with_mut(|t| {
                t.mode = match t.mode {
                    ThemeMode::Light => ThemeMode::Dark,
                    ThemeMode::Dark => ThemeMode::Light,
                };
                t.colors = match t.mode {
                    ThemeMode::Light => ColorTokens::default(),
                    ThemeMode::Dark => dark_colors(),
                };
            });
        })
        .child(
            rect()
                .width(Size::px(50.0))
                .height(Size::px(26.0))
                .background(theme.colors.surface)
                .corner_radius(13.0)
                .border(Border::new()
                    .width(1.0)
                    .fill(theme.colors.border)
                )
                .child(
                    rect()
                        .position(Position::Absolute)
                        .left(Size::px(if theme.mode == ThemeMode::Light { 2.0 } else { 24.0 }))
                        .width(Size::px(22.0))
                        .height(Size::px(22.0))
                        .background(theme.colors.primary)
                        .corner_radius(11.0)
                )
        )
}

Theme-Aware Components

fn card() -> impl IntoElement {
    let theme = use_consume::<State<Theme>>();

    rect()
        .background(theme.colors.surface)
        .border(Border::new()
            .width(1.0)
            .fill(theme.colors.border)
        )
        .corner_radius(12.0)
        .padding(16.0)
        .child(
            label()
                .text("Card Content")
                .color(theme.colors.text)
        )
}

Component Theming

Freya components support custom themes through theme partials.

Button Theming

Button::new()
    .theme_colors(ButtonColorsThemePartial {
        background: Color::from_rgb(79, 70, 229),
        color: Color::WHITE,
        hover_background: Color::from_rgb(67, 56, 202),
        pressed_background: Color::from_rgb(55, 48, 163),
        ..Default::default()
    })
    .theme_layout(ButtonLayoutThemePartial {
        padding: 16.0,
        corner_radius: 8.0,
        ..Default::default()
    })

Input Theming

Input::new(value)
    .theme_colors(InputColorsThemePartial {
        background: Color::WHITE,
        border: Color::from_rgb(200, 200, 200),
        focused_border: Color::from_rgb(79, 70, 229),
        text: Color::BLACK,
        placeholder: Color::GRAY,
        ..Default::default()
    })

Switch Theming

Switch::new()
    .theme(SwitchTheme {
        active_background: Color::from_rgb(79, 70, 229),
        inactive_background: Color::from_rgb(200, 200, 200),
        thumb: Color::WHITE,
        ..Default::default()
    })

Semantic Colors

Use semantic color names that describe purpose, not appearance.

Define Semantic Colors

struct SemanticColors {
    // Backgrounds
    background_primary: Color,
    background_secondary: Color,
    background_elevated: Color,

    // Text
    text_primary: Color,
    text_secondary: Color,
    text_disabled: Color,

    // Interactive
    interactive_primary: Color,
    interactive_hover: Color,
    interactive_pressed: Color,

    // Status
    status_success: Color,
    status_warning: Color,
    status_error: Color,
    status_info: Color,
}

Usage

// Good: Semantic name
label().color(colors.text_secondary)

// Bad: Appearance-based name
label().color(colors.gray)

[!TIP] Why Semantic Colors? Semantic names let you change the actual color values without updating every component. “text_secondary” can be gray in light mode and light gray in dark mode.


Spacing System

Consistent spacing creates visual harmony.

Spacing Scale

struct SpacingTokens {
    xs: f32,   // 4px
    sm: f32,   // 8px
    md: f32,   // 16px
    lg: f32,   // 24px
    xl: f32,   // 32px
    xxl: f32,  // 48px
}

impl Default for SpacingTokens {
    fn default() -> Self {
        Self {
            xs: 4.0,
            sm: 8.0,
            md: 16.0,
            lg: 24.0,
            xl: 32.0,
            xxl: 48.0,
        }
    }
}

Usage

rect()
    .padding(spacing.md)
    .gap(spacing.sm)
    .margin_vertical(spacing.lg)

Typography System

Consistent typography improves readability.

Typography Tokens

struct TypographyTokens {
    // Font sizes
    font_xs: f32,
    font_sm: f32,
    font_md: f32,
    font_lg: f32,
    font_xl: f32,
    font_2xl: f32,
    font_3xl: f32,

    // Font weights
    weight_normal: FontWeight,
    weight_medium: FontWeight,
    weight_semibold: FontWeight,
    weight_bold: FontWeight,

    // Line heights
    leading_tight: f32,
    leading_normal: f32,
    leading_relaxed: f32,
}

Usage

// Heading
label()
    .text("Welcome")
    .font_size(typography.font_2xl)
    .font_weight(typography.weight_bold)

// Body
label()
    .text("Description")
    .font_size(typography.font_md)
    .font_weight(typography.weight_normal)
    .line_height(typography.leading_relaxed)

// Caption
label()
    .text("Helper text")
    .font_size(typography.font_sm)
    .color(colors.text_secondary)

Complete Theme Example

// Full theme structure
#[derive(Clone, PartialEq)]
struct Theme {
    colors: ColorTokens,
    spacing: SpacingTokens,
    typography: TypographyTokens,
    radii: RadiusTokens,
    shadows: ShadowTokens,
}

// Usage in app
fn themed_app() -> impl IntoElement {
    let theme = use_state(|| Theme::default());

    rect()
        .expanded()
        .background(theme.colors.background)
        .color(theme.colors.text)
        .padding(theme.spacing.md)
        .direction(Direction::Vertical)
        .gap(theme.spacing.lg)
        .child(
            // Header
            rect()
                .padding(theme.spacing.md)
                .background(theme.colors.surface)
                .corner_radius(theme.radii.lg)
                .shadow(theme.shadows.sm)
                .child(
                    label()
                        .text("My App")
                        .font_size(theme.typography.font_xl)
                        .font_weight(theme.typography.weight_bold)
                )
        )
        .child(
            // Content card
            rect()
                .padding(theme.spacing.lg)
                .background(theme.colors.surface)
                .corner_radius(theme.radii.lg)
                .shadow(theme.shadows.md)
                .direction(Direction::Vertical)
                .gap(theme.spacing.md)
                .child(
                    label()
                        .text("Welcome!")
                        .font_size(theme.typography.font_2xl)
                        .font_weight(theme.typography.weight_bold)
                )
                .child(
                    label()
                        .text("This is a themed application.")
                        .font_size(theme.typography.font_md)
                        .color(theme.colors.text_secondary)
                )
        )
}

Best Practices

1. Use Semantic Names

// Good
colors.status_error
colors.text_primary
spacing.component_padding

// Bad
colors.red
colors.black
spacing.px16

2. Keep Tokens Consistent

Use the same scale for spacing, font sizes, etc. across your app.

3. Support Both Modes

Test your app in both light and dark modes. Ensure text is readable in both.

4. Provide Contrast

5. Document Your Tokens

Keep a reference of all your tokens so you use them consistently.


Summary

In this tutorial, you learned:

Built-in Theme System

Custom Theme Approach

[!TIP] Which Approach? Use the built-in theme system for most applications - it’s simpler and integrates with all Freya components automatically. Use the custom design tokens approach when you need maximum control over your theming system.


Previous: Part 9: Animation ←

Next: Part 11: Accessibility →

In the next tutorial, we’ll explore accessibility - making your applications usable by everyone, including keyboard navigation and screen reader support.