Overlays
Popovers, panels, tooltips, context menus, the command palette, and toasts: how they open and 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. To show it, render it. To hide it, stop rendering it. While it's open, the framework guarantees what happens to input.

Captured on macOS 27.0, 2026-10-22.

Captured on NixOS 26.11 (Zokor), Hyprland, 2026-10-22.

Captured on Windows 11 26H2, 2026-10-22.
The three kinds
popover(id), panel(id), and tooltip() come from the prelude. Each one 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. 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, and the panel stays | Sheet-like forms, quick pickers |
tooltip() | Above its anchor | None, because it takes no input | Hints |
Those are the default modalities. To change one, call .modality(..) with Modal, LightDismiss, or Passive. Placement is Below, Above, Leading, or Trailing. Panels ignore it.
A popover hangs off the element that it's 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 uses the same call. Because a panel 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, such as .tooltip("Command palette") on a button or an icon button. Use the tooltip() element when the hint needs content of its own.
Closing
You decide when an overlay closes. 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 lost activation). In the example, both handlers clear a flag and call cx.notify().
An overlay also closes when its anchor leaves the tree. It doesn't close when focus moves between your own windows.
Every overlay that isn't passive is a focus scope. Opening it saves focus, and closing it hands focus back. A modal overlay traps Tab. An Escape press that nothing inside handles closes the innermost overlay.
Context menus
.context_menu(build) on any element adds a native menu that opens on right-click, Control-click, or the menu key. The closure runs when the menu opens, so the items can depend on the current state. Each item dispatches an action, which reaches the focused view the same way a key binding does.
/// 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. On macOS, the menu is an NSMenu. On other platforms, it's a drawn menu in the overlay layer. 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 a hover) never reaches what's under it. That includes native views, such as a WKWebView, a CEF view, or a GPU surface. The example puts a native button and a live web page under every overlay and counts the input that each one receives. The gate fails if a count changes.
How the framework enforces this 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. While any overlay is open, one event monitor applies the outside-press and Escape rules. - Linux, Windows, and 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.
On every OS, a press outside a light-dismiss overlay closes it and is consumed, release included. The element under the pointer sees nothing. macOS menus already behave this way.
Declared accelerators run through the main menu before the window's views, so an embedded page can't swallow a shortcut while an overlay is up.
Native surfaces that the OS composites above GPUI (WebView2, WPE) aren't covered yet. They need either the overlay composition surface under nd-gpui/fast or a child popup window, which lands with those engines. For details, 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's a controlled element: 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 216.
Rows are palette_item(id, title) with .subtitle, .icon, .hint (a shortcut label), and .completion (the text that the field completes inline when the query is a prefix). Notes ranks notes first and commands second, so a title that starts with the query offers itself. The palette shows exactly the rows you give it, in order. You do the filtering, and fuzzy.rs has the ranker that notes uses. When nothing matches, on_submit fires with the typed text.
Toasts
toast_overlay(id) shows one window's toasts at the bottom, one at a time. You own the list: 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 425.
A toast goes away after 5 seconds. .persistent() keeps it until it's dismissed, .timeout(..) changes the time, and .action("Undo") adds one button. A click on that button arrives at on_action before the dismissal. A .high_priority() toast interrupts the one on screen, which is dismissed with the reason Interrupted. Normal toasts queue in order.
The gallery has a page for the palette and a page for toasts.