Rust egui Nested Layouts — Build a Two-Column Dashboard (Ep 7)
Video: Rust egui Nested Layouts — Build a Two-Column Dashboard (Ep 7) by Taught by Celeste AI - AI Coding Coach
One
ui.horizontalcontaining twoui.verticalblocks. The classic sidebar-plus-main-content layout.
In Episode 6 we put widgets in a row with ui.horizontal. Today we nest layouts: a horizontal layout that contains two vertical layouts produces a two-column dashboard. Sidebar on the left, details on the right.
This is the most useful layout combination in egui. Most desktop apps look like this somewhere — file tree + content, channel list + messages, navigation + page body.
What we are building
A simple dashboard. Left column: navigation buttons (Overview / Stats / Settings). Right column: a "Details" panel showing the currently selected page name and a visit counter.
The script
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Dashboard");
ui.separator();
ui.horizontal(|ui| {
// LEFT COLUMN
ui.vertical(|ui| {
ui.set_max_width(150.0);
ui.label("Navigation");
ui.separator();
if ui.button("Overview").clicked() {
self.selected = String::from("Overview");
}
if ui.button("Stats").clicked() {
self.selected = String::from("Stats");
}
if ui.button("Settings").clicked() {
self.selected = String::from("Settings");
}
});
ui.separator();
// RIGHT COLUMN
ui.vertical(|ui| {
ui.label("Details");
ui.separator();
ui.horizontal(|ui| {
ui.label("Page:");
ui.label(&self.selected);
});
ui.horizontal(|ui| {
ui.label("Visits:");
ui.label(format!("{}", self.count));
});
if ui.button("Visit").clicked() {
self.count += 1;
}
});
});
});
The outer horizontal lays out the two columns left-to-right. Each vertical inside flows widgets top-to-bottom within its column.
ui.vertical
ui.vertical(|ui| {
// widgets stack top to bottom (the default)
});
In an outer vertical context (which the CentralPanel is by default), ui.vertical is a no-op semantically — widgets already flow top to bottom. But inside ui.horizontal, you need it: without vertical, the inner widgets would continue flowing horizontally and you would not get column behaviour.
The pattern is:
ui.horizontalsays "flip to row direction, then flip back."ui.verticalsays "flip to column direction, then flip back."
Nest them and you build any rectangular layout.
ui.set_max_width
ui.vertical(|ui| {
ui.set_max_width(150.0);
// ...
});
By default each vertical inside horizontal claims as much width as its widest widget. That makes the columns asymmetric and unstable — different page selections might widen the right column, which would push the left column around.
ui.set_max_width(150.0) pins the left column to 150 pixels. The right column gets the remaining space. Stable layout, regardless of content.
For more control there is also ui.set_min_width, ui.set_max_height, ui.set_min_height. The four together let you specify any rectangle.
The middle separator
ui.separator();
Between the two vertical blocks. Inside a horizontal layout, ui.separator() draws a vertical line — egui infers the orientation from the parent layout direction.
This visual divider separates the navigation column from the details column. Optional, but conventional for sidebar layouts.
State-driven content
if ui.button("Overview").clicked() {
self.selected = String::from("Overview");
}
Clicking a navigation button writes a string to self.selected. The right column reads from that string:
ui.horizontal(|ui| {
ui.label("Page:");
ui.label(&self.selected);
});
Click "Stats" — the right column shows "Stats". Click "Settings" — it shows "Settings". A single shared state field; no event subscriptions, no observer pattern.
For richer dashboards, selected would typically be an enum (with derive(PartialEq)), and the right column would match on it to render different widgets per page:
match self.selected {
Page::Overview => ui.label("Welcome!"),
Page::Stats => self.render_stats(ui),
Page::Settings => self.render_settings(ui),
}
We will adopt the enum-based pattern in Episode 11 with a real recipe-viewer app.
Three layouts deep
ui.horizontal(|ui| { // outer: two columns
ui.vertical(|ui| { // left column
// ...
});
ui.vertical(|ui| { // right column
ui.horizontal(|ui| { // row inside right column
ui.label("Page:");
ui.label(&self.selected);
});
});
});
That is three layout levels deep. egui handles arbitrary nesting; you can keep going. Nesting four or five levels deep is fine.
The upper limit is your own readability. Past three or four levels deep, factor a sub-tree into its own method:
impl MyApp {
fn show_navigation(&mut self, ui: &mut egui::Ui) {
ui.set_max_width(150.0);
ui.label("Navigation");
if ui.button("Overview").clicked() { self.selected = "Overview".into(); }
// ...
}
}
Then in update:
ui.horizontal(|ui| {
ui.vertical(|ui| self.show_navigation(ui));
ui.separator();
ui.vertical(|ui| self.show_details(ui));
});
Cleaner, easier to find when reading.
Running it
cargo run. The window has a heading, then two columns. Left column contains three buttons. Right column reads "Details" with two lines of state and a "Visit" button.
Click "Stats" on the left — the right column updates to read "Page: Stats". Click "Visit" — the visit counter goes up. The two columns interact through self, which is the universal coordination point.
SidePanel — the dedicated alternative
For dashboards specifically, egui has SidePanel:
egui::SidePanel::left("nav").show(ctx, |ui| {
// navigation widgets
});
egui::CentralPanel::default().show(ctx, |ui| {
// main content
});
SidePanel is a top-level container (like CentralPanel) that docks to a window edge. It is more polished than horizontal + vertical for permanent sidebars: it has handled resizing, persistent width, and proper z-ordering. But it cannot be nested inside other widgets, only inside the top-level frame.
For sub-panels inside a content area (as in this episode), the horizontal+vertical combination is the right tool.
Common mistakes
Forgetting set_max_width on the sidebar. The left column expands to fit content, the right column compresses. The layout looks unstable.
Using set_max_width instead of set_min_width. max caps the size; min enforces a floor. For a sidebar, you usually want both: min == max makes the column a fixed width.
Trying to align widgets across separate verticals. They live in independent layout contexts. For aligned multi-row, multi-column data, use Grid (Episode 8).
Missing the inner separator. Without it, the two columns visually merge. A small thing that materially helps clarity.
What's next
Next episode: grid layout. When you need aligned columns across rows — a contact form, a settings table, a multi-column list — egui::Grid does in two lines what nested layouts cannot do at all.
Recap
ui.horizontal + ui.vertical for two-column dashboards. set_max_width to pin a column to a fixed size. ui.separator between columns for visual clarity. SidePanel for top-level docked sidebars. Factor large sub-trees into methods to keep update readable.
Next episode: grid layout. See you in the next one.