Skip to Content
ftui-widgetsModal stack

Modal stack

A modal is any overlay that temporarily owns the user’s attention: a confirm dialog, a search overlay, a file picker, a command palette. FrankenTUI’s ModalStack is a real stack — you can open a modal on top of another modal on top of another — with focus-trap integration, animation phases, and deterministic input consumption.

This page documents the push / pop semantics, how input flows, and how to combine the stack with the focus manager so Tab stays inside the top modal.

The shape of the API

Source: modal/stack.rs:233.

pub struct ModalStack { /* ... */ } impl ModalStack { pub fn push(&mut self, modal: Box<dyn StackModal>) -> ModalId; pub fn push_with_focus(&mut self, modal: Box<dyn StackModal>, trap: FocusGroup) -> ModalId; pub fn pop(&mut self) -> Option<ModalResult>; pub fn pop_id(&mut self, id: ModalId) -> Option<ModalResult>; pub fn pop_all(&mut self) -> Vec<ModalResult>; pub fn top(&self) -> Option<&(dyn StackModal + 'static)>; pub fn top_mut(&mut self) -> Option<&mut (dyn StackModal + 'static)>; }

A StackModal is the trait every modal implements (modal/stack.rs:130). Pre-built implementations ship in modal/dialog.rs and via the WidgetModalEntry<W> wrapper at modal/stack.rs:714, which lets you use any Widget + Send as a modal body.

Push / pop

use ftui_widgets::modal::{ModalStack, dialog::ConfirmDialog}; let mut stack = ModalStack::new(); // Open a confirm dialog. let id = stack.push(Box::new(ConfirmDialog::new("Delete?"))); // Later, dismiss it. if let Some(result) = stack.pop() { match result { ModalResult::Confirmed => delete_thing(), ModalResult::Cancelled => {} _ => {} } }
  • push returns a unique ModalId so you can target dismissal even if more modals have been opened on top.
  • pop removes the top modal and returns its ModalResult.
  • pop_id(id) removes a specific modal out of the middle of the stack, preserving the rest.
  • pop_all closes everything and returns results in close order.

Input consumption hierarchy

Only the top modal receives input. Below it, nothing sees events.

Inside the top modal:

  1. The modal’s own handle_event runs first.
  2. If it doesn’t consume the event, stack-level defaults kick in (configurable): Esc pops, Enter confirms on a dialog, backdrop click pops if BackdropConfig::dismissible is set.
  3. Unhandled events are dropped — they never leak to lower modals or to the background view.

There is intentionally no event tunnelling. If a background widget wants to observe modal open / close, use the ModalResult returned from pop or watch the stack depth; don’t try to peek at events behind the modal.

Focus trap integration

push_with_focus(modal, group) opens a modal and simultaneously pushes a focus trap for group onto the FocusManager. Tab navigation is confined to group until the modal closes, at which point pop_trap restores the prior focus.

use ftui_widgets::focus::FocusGroup; let group = FocusGroup::new(); let id = stack.push_with_focus(Box::new(my_modal), group);

There is also a FocusAwareModalStack helper (modal/stack.rs:835) that pairs a ModalStack with a FocusManager so push_with_focus and pop_with_focus stay in lockstep.

Animation phases

Modals do not appear or disappear instantly. Each one moves through a small state machine:

PhaseDurationWhat it does
Entering~200 ms by defaultScale-in + fade-in on the body; backdrop fades in
Idleuntil dismissedSteady state; fully interactive
Exiting~200 ms by defaultScale-out + fade-out; backdrop fades out
ClosedStack entry is dropped

Source: modal/animation.rs.

You can disable animations globally for reduced-motion users, or per-modal via BackdropConfig / modal builder options. While in Entering or Exiting, the modal is non-interactive even though its cells are still visible; handle_event returns “consumed” to avoid accidental actions mid-animation.

Worked example: confirm-before-delete

A small but complete flow using a pre-built ConfirmDialog.

use ftui_widgets::modal::{dialog::ConfirmDialog, ModalResult}; pub struct Model { items: Vec<Item>, selected: Option<usize>, modal_stack: ftui_widgets::modal::ModalStack, } impl Model { fn on_delete_pressed(&mut self) { // Open the confirm dialog. self.modal_stack.push(Box::new( ConfirmDialog::new("Delete?") .description("This cannot be undone.") .confirm_label("Delete") .cancel_label("Keep"), )); } fn update(&mut self, event: Event) { // Modals get first crack at input. if self.modal_stack.top_mut().is_some() { // (stack handles the event; skip details) return; } // … normal update for non-modal state } fn on_modal_closed(&mut self, result: ModalResult) { if matches!(result, ModalResult::Confirmed) { if let Some(i) = self.selected.take() { self.items.remove(i); } } } // `Model::view` is `&self`; this helper uses `&mut self` so the // modal stack can mutate its internal per-frame state. Wrap // `modal_stack` in `RefCell<ModalStack>` (or keep a `&mut` helper // like this one and call it via a thin `view(&self) { … }` shim). fn render_with_modals(&mut self, frame: &mut ftui_render::frame::Frame) { // Render normal content first. render_normal_view(frame, &self.items, self.selected); // Let the modal stack render on top. self.modal_stack.render(frame); } }

The order matters: the modal stack renders last, so its cells land on top of whatever was there.

Backdrop configuration

BackdropConfig (modal/container.rs) exposes:

  • dismissible — backdrop click pops the modal
  • opacity — how dark the scrim is (0.0 – 1.0)
  • blur — optional Gaussian scrim effect on capable terminals
  • escape_dismisses — whether Esc auto-pops

Defaults are conservative: dismissible, ~40% scrim, no blur, Esc dismisses.

Pitfalls

  • Rendering the stack before the main view. Your modal ends up behind its own background. Always render the stack last.
  • Pushing without a focus group when the modal has inputs. Tab will escape the modal and land on a background button. Use push_with_focus.
  • Pop during render. Don’t mutate the stack from view(); modal transitions are driven by update() and by the modal’s own event handler.
  • Assuming push fires input capture immediately. During Entering, the modal consumes but does nothing with events. If you need synchronous focus, wait for the first Idle tick.
  • Long-lived mutable borrows. top_mut() borrows the stack; don’t hold it across other stack operations.

Where next