Overlays
Popovers, panels, tooltips, context menus, the command palette and toasts: how they open, how they close, and why a click inside one never reaches the page or button under it.
An overlay is an element in your tree that floats above its anchor. You show it by rendering it and hide it by not rendering it. The framework guarantees what happens to input while it is open.

Captured on macOS 27.0, 2026-10-14.
The three kinds
popover(id), panel(id) and tooltip() come from the prelude. Each is a container: add children as content and size it with the usual style methods. An overlay takes no room in its parent's layout, and its parent is its anchor, so it closes with the anchor's subtree.
| Element | Placed | Outside presses | Use |
|---|---|---|---|
popover(id) | below its anchor (.placement(..) moves it) | close it, and are consumed | menus of controls, pickers |
panel(id) | over the window, near the top | consumed, panel stays | sheet-like forms, quick pickers |
tooltip() | above its anchor | none: it takes no input | hints |
Those are the default modalities. .modality(..) changes one: Modal, LightDismiss or Passive. Placement is Below, Above, Leading or Trailing; panels ignore it.
A popover hangs off the element it is a child of:
/// Anchored to the element it is a child of; `on_dismiss` is where the app closes it.
fn popover_view(&self, cx: &mut Context<Self>) -> impl IntoElement {
popover("pop").w(px(300.)).h(px(200.)).p(px(12.)).child(text("Clicks in here stay in here.")).on_dismiss(
cx.listener(|this, _: &Dismissed, _, cx| {
this.popover = false;
cx.notify();
}),
)
}
From examples/overlays/src/main.rs, line 39.
A panel is the same call. Because it is modal, the rest of the window stays out of reach until the app drops it:
/// Modal: presses outside are consumed and the panel stays until Esc or the app closes it.
fn panel_view(&self, cx: &mut Context<Self>) -> impl IntoElement {
panel("panel").w(px(260.)).h(px(120.)).p(px(12.)).child(text("A modal panel. Esc closes it.")).on_dismiss(
cx.listener(|this, _: &Dismissed, _, cx| {
this.panel = false;
cx.notify();
}),
)
}
From examples/overlays/src/main.rs, line 51.
Most controls have a shortcut for tooltips, .tooltip("Command palette") on a button or icon button. Use the tooltip() element when the hint needs content of its own.
Closing
Dismissal is yours. An overlay never removes itself; it reports Dismissed { reason } to on_dismiss and you stop rendering it. The reason is Escape, OutsidePress or Deactivated, the app losing activation. In the example both handlers clear a flag and call cx.notify().
An overlay also closes when its anchor leaves the tree. It does not close when focus moves between your own windows.
Every non-passive overlay is a focus scope. Opening saves focus, closing hands it back, a modal one traps Tab, and an Escape that nothing inside took closes the innermost overlay.
Context menus
.context_menu(build) on any element adds a native menu on right click, control-click or the menu key. The closure runs when the menu opens, so the items can depend on current state. Each item dispatches an action, which reaches the focused view like a key binding would.
/// Built when the menu opens; each item dispatches an action.
fn menu_items(menu: ContextMenu, _window: &mut Window, _cx: &mut App) -> ContextMenu {
menu.item("Choose", Choose).separator().item("Again", Choose)
}
From examples/overlays/src/main.rs, line 64.
item_destructive marks an item as destructive and submenu nests a menu. The menu is an NSMenu on macOS and a drawn menu in the overlay layer elsewhere. Notes attaches one to each row of its list in note_list.rs.
Input never leaks
Input inside an overlay, a press, a scroll or hover, never reaches what lies under it. That includes native views: a WKWebView, a CEF view, a GPU surface. The example puts a native button and a live web page under every overlay and counts what each received, and the gate fails if a count moves.
How it holds depends on the OS:
- macOS: each overlay is its own window above the owner, so the window server routes input and nothing relies on hit-testing inside one window. This matters for hover. Tracking areas fire for covered views, so an overlay drawn inside the window would still make a web view's
:hoverflicker. Popovers areNSPopover, panels and tooltips are borderless child panels that never become key, so the owner keeps its key chrome and keys keep reaching nd's dispatch. One event monitor applies the outside-press and Escape rules while any overlay is open. - Linux, Windows, web: overlays draw in GPUI's deferred layer, above their parents, with a transparent backdrop that consumes presses. A light-dismiss backdrop lets scrolls through. Gesture hit testing starts inside the topmost overlay or hits nothing.
A press outside a light-dismiss overlay closes it and is consumed, release included, on every OS. The element under the pointer sees nothing. This matches what macOS menus already do.
Declared accelerators run through the main menu before the window's views, so an embedded page cannot swallow a shortcut while an overlay is up.
Not done: native surfaces that the OS composites above GPUI (WebView2, WPE) need either the overlay composition surface under nd-gpui/fast or a child popup window. That lands with those engines, see webview. The leak gate runs on macOS and on Hyprland.
Command palette
command_palette(id) is a glass card near the top of the window with a text field, ranked rows and keyboard navigation. It is controlled: you pass the open flag, the query and the rows, and you react to the events. Notes puts it in the scaffold's overlay slot:
.overlay(
command_palette("palette")
.open(self.palette_open)
.placeholder("Search notes and commands")
.query(self.palette_query.clone())
.items(palette_items)
.on_query_change(cx.listener(|this, e: &PaletteQueryChanged, _window, cx| {
this.palette_query = e.text.clone();
cx.notify();
}))
.on_activate(cx.listener(Self::palette_activate))
.on_submit(cx.listener(|this, e: &PaletteSubmitted, window, cx| {
// Nothing matched: typed text becomes a new note.
this.palette_open = false;
cx.notify();
let id = this.library.update(cx, |library, cx| {
let id = library.create(cx);
library.set_body(id, e.text.to_string(), cx);
id
});
this.select(id, window, cx);
}))
.on_cancel(cx.listener(|this, _: &PaletteCancelled, _window, cx| {
this.palette_open = false;
cx.notify();
})),
)
From examples/notes/src/app.rs, line 224.
Rows are palette_item(id, title) with .subtitle, .icon, .hint (a shortcut label) and .completion, the text the field completes inline when the query is a prefix. Notes ranks notes first, then commands, so a title that starts with the query offers itself. The palette shows exactly the rows you give it, in order; filtering is yours, and fuzzy.rs has the ranker notes uses. on_submit fires when nothing matched, with the typed text.
Toasts
toast_overlay(id) shows one window's toasts, one at a time, at the bottom. The list is yours: append a toast(id, title) to show one and remove it when on_dismiss fires.
toast_overlay("toasts")
.toasts(self.toasts.clone())
.on_dismiss(cx.listener(|this, e: &ToastDismissed, _, cx| {
this.toasts.retain(|t| t.id != e.id);
cx.notify();
}))
.on_action(cx.listener(|this, _: &ToastActionClicked, _, cx| {
this.last_event = "Undo clicked".into();
cx.notify();
})),
From examples/gallery/src/lib.rs, line 432.
A toast goes away after 5 seconds. .persistent() keeps it until dismissed, .timeout(..) changes the time, and .action("Undo") adds one button whose click arrives at on_action before the dismissal. A .high_priority() toast interrupts the one on screen, which is dismissed with reason Interrupted; normal ones queue in order.
The gallery has a page each for the palette and for toasts.