egui Custom Widgets: impl Widget for Reusable Components | Rust GUI Ep 25
Video: egui Custom Widgets: impl Widget for Reusable Components | Rust GUI Ep 25 by Taught by Celeste AI - AI Coding Coach
impl egui::Widget for Toggle— turn any struct into somethingui.add(...)knows how to render.
egui's built-in widgets cover most needs. When they don't — when you want a custom toggle switch, a labelled gauge, a star-rating bar — you implement the egui::Widget trait. After that, your custom thing is added with ui.add(MyWidget::new(...)), exactly like a built-in.
This is the pattern that lets you build a vocabulary of UI components for your app. Pages get shorter; visual consistency improves; the orchestration in update becomes a list of high-level widget calls.
What we are building
A custom toggle switch. Looks like an iOS-style on/off slider — a coloured pill with a circle that slides left or right. Implements egui::Widget, so it works anywhere a built-in widget does. We use it for four boolean settings (Dark Mode, Notifications, Auto Save, Sound) in a settings grid.
The Widget trait
pub struct Toggle<'a> {
value: &'a mut bool,
label: &'a str,
}
impl<'a> Toggle<'a> {
pub fn new(value: &'a mut bool, label: &'a str) -> Self {
Self { value, label }
}
}
impl<'a> egui::Widget for Toggle<'a> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
// render and return a Response
}
}
Three pieces.
The struct carries everything the widget needs: a mutable reference to the bound value, a label, and any other configuration (animation timing, colors, sizes — whatever you want).
The new constructor is conventional. egui's own widgets follow this pattern (egui::Button::new("Save"), egui::Slider::new(&mut x, 0.0..=100.0)).
The Widget trait has one required method: fn ui(self, ui: &mut Ui) -> Response. The widget consumes self (one-shot use), draws into the ui, and returns a Response describing what happened.
The ui method
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
let size = egui::vec2(40.0, 20.0);
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
if response.clicked() {
*self.value = !*self.value;
}
let painter = ui.painter();
let bg_color = if *self.value {
egui::Color32::from_rgb(100, 180, 100)
} else {
egui::Color32::from_rgb(120, 120, 120)
};
painter.rect_filled(rect, 10.0, bg_color);
let circle_x = if *self.value {
rect.right() - 10.0
} else {
rect.left() + 10.0
};
let circle_center = egui::pos2(circle_x, rect.center().y);
painter.circle_filled(circle_center, 7.0, egui::Color32::WHITE);
ui.label(self.label);
response
}
Five steps:
- Allocate — reserve a rectangle of the chosen size. We get back the rectangle and a Response sensitised to clicks.
- Handle events — if clicked, toggle the bound bool.
- Paint — draw the background pill and the white circle. Position the circle based on the bool's current value.
- Add the label —
ui.label(self.label)after painting puts the label next to the toggle in the layout flow. - Return the Response — so callers can chain
.changed(),.on_hover_text(...), etc.
That's it. Not magic — just allocate_exact_size, painter.rect_filled, painter.circle_filled, and a click handler. The Widget trait provides the integration point with egui's call site (ui.add(Toggle::new(...))).
Using the custom widget
ui.add(Toggle::new(&mut self.dark_mode, "Dark Mode"));
That is the entire call site. ui.add(...) accepts anything that implements Widget. It calls the widget's ui method, returns the Response. Callers don't know or care that this is a custom widget — they treat it like a button or a checkbox.
This is the big win. After defining Toggle once, everywhere it appears in your code is one line. Without the trait, you would have a 15-line block per toggle, repeated for each setting.
Lifetimes
pub struct Toggle<'a> {
value: &'a mut bool,
label: &'a str,
}
The 'a lifetime parameter says: this Toggle borrows from somewhere; both the bool reference and the label string slice live at least as long as the toggle does.
In practice this means: Toggle::new(&mut self.dark_mode, "Dark Mode") is built fresh each frame inside update, used immediately by ui.add, and dropped at the end of the function call. The lifetime tracks the borrow correctly without you having to worry about it.
Animation (a small upgrade)
The toggle in this episode snaps instantly from off to on. For a smoother feel, animate the circle's position with ctx.animate_value_with_time:
let circle_x = ui.ctx().animate_value_with_time(
ui.id().with("toggle_pos"),
if *self.value { rect.right() - 10.0 } else { rect.left() + 10.0 },
0.1, // 0.1s animation
);
animate_value_with_time(id, target, time) interpolates from the current value to the target over the given seconds. Each ID gets its own animation. The value smoothly slides to position over 100ms instead of jumping.
When to write a custom widget
Build a custom widget when:
- You want a visual style that the built-ins don't provide.
- You are pasting the same UI block in three or more places.
- The behaviour is composite (label + input + validation, all in one).
- You want a clean abstraction over
painter.*calls.
Don't build a custom widget when:
- A built-in does 80% of what you want — extend it via
Response.on_hover_text,Styletweaks, orRichText. - The "widget" is really a panel or a sub-tree of widgets — that's a function, not a
Widget.
Common mistakes
Forgetting Sense::click() (or whatever sense you need). The widget paints but doesn't respond to events.
Returning the wrong Response. Each widget should return its response, not a sub-widget's. If the widget contains multiple interactive parts, decide which is the "primary" one and return that.
Borrowing self.value outside ui and inside. The lifetime would conflict. Resolve by storing only what you need in the struct.
Calling ui.add(MyWidget::new(...)) repeatedly with the same widget instance. Widgets are one-shot — ui consumes self. Build a fresh instance per call.
What's next
Next episode: theming — visuals, colors, spacing. Make every widget across the app look the way you want by building a Style and applying it via ctx.set_style. Combined with the Widget trait, you get a custom-styled component library.
Recap
impl egui::Widget for MyType so ui.add(MyType::new(...)) just works. The trait has one required method: fn ui(self, ui: &mut Ui) -> Response. Allocate, handle events, paint, return Response. Use lifetimes for borrowed state. Animate movement with ctx.animate_value_with_time.
Next episode: theming. See you in the next one.