Building a Pause Menu with Bevy's Built-in UI: Flexbox, ECS, and the Trade-offs
Bevy's built-in UI treats every element as an ECS entity with flexbox-style Style components. Here's how the layout works, a working pause-menu pattern, and the trade-offs to weigh.
19 Aug 2025, 12:03 UTC

You need a pause menu. In most engines that means learning a separate UI framework, wiring up event callbacks, and keeping two object models in sync. In Bevy, a UI element is just another entity with components, so your pause menu lives in the same ECS world as your gameplay — and the same systems that move your player can also show, hide, and restyle buttons.
The trade-off: Bevy's UI is still marked experimental, and its styling vocabulary is deliberately small. This post walks through how the layout actually works, a concrete pause-menu example, and how to verify the behavior yourself on a current release.
UI as entities, not a widget tree
Bevy UI builds a flexbox-style layout tree out of ordinary entities. The core components are:
NodeBundle— a container or box, with aStylecomponent describing size, position, flex direction, padding, and gaps.TextBundle— a text run, with font, size, and color.BackgroundColorandInteraction— for backgrounds and hover/press states on buttons.
Because layout is computed from components each frame (during the render extract/prepare work), changing a Style value — say, switching display: Display::None to Display::Flex — triggers a re-layout automatically. There is no separate "invalidate" call. Window resizes and DPI changes are handled the same way: the layout re-solves against the new window size without you writing resize handlers.
A minimal pause menu
The example below targets Bevy 0.11–0.12 era APIs; UI APIs have shifted between releases, so check the ui examples in the Bevy repo matching your version before copying verbatim. The idea: spawn a full-screen column container with two text "buttons", and toggle its visibility based on a Paused resource.
use bevy::prelude::*;
#[derive(Resource, Default)]
struct Paused(bool);
#[derive(Component)]
struct PauseMenuRoot;
fn spawn_pause_menu(mut commands: Commands, asset_server: Res<AssetServer>) {
let font = asset_server.load("fonts/FiraSans-Bold.ttf");
commands
.spawn((
NodeBundle {
style: Style {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
row_gap: Val::Px(16.0),
display: Display::None, // hidden by default
..default()
},
background_color: Color::rgba(0.0, 0.0, 0.0, 0.6).into(),
..default()
},
PauseMenuRoot,
))
.with_children(|parent| {
for label in ["Resume", "Quit"] {
parent.spawn(TextBundle::from_section(
label,
TextStyle { font: font.clone(), font_size: 40.0, color: Color::WHITE },
));
}
});
}
fn toggle_pause(
keys: Res<Input<KeyCode>>,
mut paused: ResMut<Paused>,
mut query: Query<&mut Style, With<PauseMenuRoot>>,
) {
if keys.just_pressed(KeyCode::Escape) {
paused.0 = !paused.0;
for mut style in &mut query {
style.display = if paused.0 { Display::Flex } else { Display::None };
}
}
}Run this inside a normal Bevy app (cargo run from your project, with a font asset at assets/fonts/FiraSans-Bold.ttf — any TTF works if you adjust the path). Pressing Escape flips the display property, and the layout system shows or hides the overlay on the next frame. No manual repaint, no separate UI thread.
The useful consequence: pausing gameplay is the same resource check. Gate your gameplay systems on paused.0 (or use run conditions / schedule states) and the menu and the simulation can never disagree about whether the game is paused.
Why this model pays off
Two properties make the ECS approach more than a novelty:
- Dynamic updates are free-form. Any system with a
Query<&mut Text>can rewrite a label — a health counter, a timer, a localization swap — and the change appears next frame. There is no data-binding layer to learn. - Layout is reactive. Because positions are solved from
Styleevery frame against the current window, resizing the window or moving to a high-DPI display re-flows the menu without explicit handling. You can verify this by running the example and dragging the window corner: the buttons stay centered.
Bevy also supports hot-reloading of assets (enable the file_watcher feature): editing a font or other loaded asset while the app runs refreshes it in place, which shortens the tweak-and-look loop for UI work.
Limitations worth knowing before you commit
- Experimental API. Bevy's own docs flag UI as experimental, and component names and bundle shapes have changed across releases. Pin your Bevy version and expect migration work on upgrades.
- Small styling vocabulary. The flexbox implementation covers alignment, spacing, borders, and solid backgrounds, but there are no built-in shadows, gradients, transitions, or rich text layout. If you need those, you either build them yourself (custom materials on UI nodes) or reach for an ecosystem crate such as
bevy_eguifor immediate-mode tooling UIs. - Input handling is manual. Clickable buttons work via the
Interactioncomponent, but focus management, keyboard navigation, and accessibility are largely do-it-yourself at this stage.
Verify it yourself
Don't take any of this on faith — UI details shift between Bevy versions. Clone the Bevy repository at the release tag you use and run cargo run --example ui (and cargo run --example button for interaction). Resize the window to confirm the layout re-solves, then edit a label string in the example, rebuild, and compare. If the pause-menu snippet above fails to compile on your version, the diff against the shipped examples will show exactly which API moved — that diff is the real documentation until the UI stabilizes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.