Back to Blog

Rust egui Nested Layouts — Build a Two-Column Dashboard (Ep 7)

Sandy LaneSandy Lane

Video: Rust egui Nested Layouts — Build a Two-Column Dashboard (Ep 7) by Taught by Celeste AI - AI Coding Coach

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

One ui.horizontal containing two ui.vertical blocks. 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.horizontal says "flip to row direction, then flip back."
  • ui.vertical says "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.

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.