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 => {}
_ => {}
}
}pushreturns a uniqueModalIdso you can target dismissal even if more modals have been opened on top.popremoves the top modal and returns itsModalResult.pop_id(id)removes a specific modal out of the middle of the stack, preserving the rest.pop_allcloses 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:
- The modal’s own
handle_eventruns first. - If it doesn’t consume the event, stack-level defaults kick in
(configurable):
Escpops,Enterconfirms on a dialog, backdrop click pops ifBackdropConfig::dismissibleis set. - 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:
| Phase | Duration | What it does |
|---|---|---|
Entering | ~200 ms by default | Scale-in + fade-in on the body; backdrop fades in |
Idle | until dismissed | Steady state; fully interactive |
Exiting | ~200 ms by default | Scale-out + fade-out; backdrop fades out |
Closed | — | Stack 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
Escauto-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 byupdate()and by the modal’s own event handler. - Assuming
pushfires input capture immediately. DuringEntering, the modal consumes but does nothing with events. If you need synchronous focus, wait for the firstIdletick. - Long-lived mutable borrows.
top_mut()borrows the stack; don’t hold it across other stack operations.
Where next
Trap stack internals — exactly what push_trap / pop_trap do.
A flagship modal that uses the stack plus a focus trap.
Command paletteWhere the stack sits in your view() function.
Links inside a modal — gracefully degrade on non-OSC-8 terminals.
HyperlinksHow this piece fits in widgets.
Widgets overview