Back to Blog

egui Theming: Custom Colors, Rounding & Spacing | Rust GUI Ep 26

Celest KimCelest Kim

Video: egui Theming: Custom Colors, Rounding & Spacing | Rust GUI Ep 26 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

ctx.set_style(...) plus a built Style derived from Visuals::dark(). Override accent colors, corner radius, and spacing to match your design.

egui's default look is good. But "your app should look like every other egui app" is rarely the goal. Theming — adjusting colors, rounding, fonts, padding — is how you make an app feel like yours.

We've done bits of theming already: ctx.set_visuals(Visuals::dark()) in Episode 18, ctx.set_pixels_per_point in Episode 16, font tweaking in Episode 5. Today is a focused look at the whole theming surface, with a live editor that lets you tweak the accent colour, rounding, and spacing — and see every widget in the app respond.

What we are building

A theme editor. Sidebar with controls (dark mode toggle, accent color picker, rounding slider, spacing slider, reset button). Central panel with sample widgets — text input, checkbox, slider, three buttons — that all respond to the theme settings. Drag a slider, see every widget across the app re-render with new corner radius.

Visuals — the color system

let mut visuals = if self.dark_mode {
  egui::Visuals::dark()
} else {
  egui::Visuals::light()
};

let accent = egui::Color32::from_rgb(/* ... */);
visuals.selection.bg_fill = accent;
visuals.widgets.active.bg_fill = accent;

Visuals holds every color in egui's UI. Visuals::dark() and Visuals::light() are presets. From there you mutate fields:

  • visuals.selection.bg_fill — selected text background, picked items.
  • visuals.widgets.active.bg_fill — buttons being pressed.
  • visuals.widgets.hovered.bg_fill — buttons on hover.
  • visuals.widgets.inactive.bg_fill — default button color.
  • visuals.window_fill — panel background.
  • visuals.panel_fill — top-level panel background.
  • visuals.extreme_bg_color — text input backgrounds.
  • visuals.override_text_color = Some(Color32::RED) — global text color.

There are dozens more. Most apps only touch a handful — the accent color, the highlight color, maybe the button background. The rest can stay at preset defaults.

Style — the layout system

let mut style = (*ctx.style()).clone();
style.visuals = visuals;
style.spacing.item_spacing = egui::vec2(self.spacing, self.spacing);
ctx.set_style(style);

Style is the broader container — it holds Visuals plus spacing, sizing, and font configuration.

Useful fields:

  • style.spacing.item_spacing — gap between vertically adjacent widgets.
  • style.spacing.button_padding — internal padding inside buttons.
  • style.spacing.indent — indent amount for nested widgets.
  • style.text_styles — font sizes for Body, Heading, Button, Monospace, etc.

Set them all on the Style, then call ctx.set_style(style) once per frame at the top of update.

Rounded corners

let rounding = egui::CornerRadius::same(self.rounding as u8);
style.visuals.widgets.noninteractive.corner_radius = rounding;
style.visuals.widgets.inactive.corner_radius = rounding;
style.visuals.widgets.active.corner_radius = rounding;
style.visuals.widgets.hovered.corner_radius = rounding;

CornerRadius is in pixels. same(value) applies the same radius to all four corners. There is also CornerRadius { nw, ne, sw, se } for asymmetric rounding.

Each widget state (noninteractive, inactive, active, hovered) has its own corner_radius. Setting all four to the same value gives consistent rounding regardless of state. If you wanted (e.g.) less rounded buttons on hover, set hovered.corner_radius differently.

Reading the current style

let mut style = (*ctx.style()).clone();

ctx.style() returns Arc<Style> — a shared reference. Dereference and clone to get a mutable copy. Mutate, then ctx.set_style(...) to install the new version.

This pattern (clone-mutate-set) is required because Style is shared globally; egui needs to know when you've finished editing.

Theming patterns

A few realistic patterns for app themes:

Single source of truth. Store the theme parameters (accent color, dark mode, rounding) in your app struct. Build the Visuals from those parameters every frame. Easy to persist and easy to expose to a settings panel.

A theme module. Centralise the build:

pub mod theme {
  use eframe::egui::*;

  pub fn build_visuals(dark: bool, accent: Color32) -> Visuals {
    let mut v = if dark { Visuals::dark() } else { Visuals::light() };
    v.selection.bg_fill = accent;
    v.widgets.active.bg_fill = accent;
    v
  }
}

Then in update:

let visuals = theme::build_visuals(self.dark_mode, self.accent);
let mut style = (*ctx.style()).clone();
style.visuals = visuals;
ctx.set_style(style);

Theme presets. Pre-built themes the user can switch between:

fn ocean_theme() -> (bool, Color32) { (true, Color32::from_rgb(70, 140, 220)) }
fn sunset_theme() -> (bool, Color32) { (false, Color32::from_rgb(255, 140, 80)) }

A dropdown of presets is much friendlier than a raw color picker.

Accessibility. High-contrast and large-text themes for users who need them. egui supports both — set_pixels_per_point(1.5) for larger UI, paired with high-contrast colors.

Visuals::dark vs visuals::light

The two presets differ in:

  • Background colors (dark grey vs nearly-white).
  • Text colors (off-white vs near-black).
  • Widget hovers and active states.

They are good baselines. Almost always you start from one of them and override a few fields. Building a complete Visuals from scratch is possible but rarely worth the time.

Running it

cargo run. The sidebar shows the four theme controls. Drag the rounding slider — every widget's corner radius updates. Drag spacing — gaps between widgets grow or shrink. Pick a different accent color — the slider handle, button hovers, and selection backgrounds change.

Notice everything stays in sync because the Style is a global object — modifying it once affects every widget that reads from it for the rest of the frame.

Common mistakes

Setting visuals once at startup. Style changes don't apply if you only set them in the CreationContext. Set them in update, every frame, so dynamic changes from your UI work.

Forgetting to clone. ctx.style() returns an Arc, which is read-only. Clone it before mutating.

Inconsistent rounding across widget states. If only inactive has rounded corners, hovered buttons get sharp corners. Update all four states.

Theming inside specific widget calls instead of globally. Per-widget styling is fine for one-off cases, but for a coherent app theme, set the Style once at the top of update.

What's next

Next episode: Vec state and CRUD. A playlist manager — songs as a Vec<Song> with add, remove, and favorite toggling. The patterns for editing collections in egui without fighting the borrow checker.

Recap

Visuals for colors. Style for layout, spacing, fonts. Build by cloning (*ctx.style()).clone(), mutate, install with ctx.set_style. Common overrides: selection.bg_fill, widgets.active.bg_fill, widgets.*.corner_radius, spacing.item_spacing. Centralise theming in a theme module if it grows.

Next episode: Vec state and CRUD. See you in the next one.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.