The Overlay Layer and Lui.Portal
LUI renders into two stacked layers: the app tree, and a dedicated overlay layer drawn on top
of it. Lui.Portal(key, content) renders content into that overlay layer instead of inline,
while the call still lives in your component tree (so it re-renders with your state and unmounts
when you stop calling it). This is how overlays draw above all other content and escape any
overflow-hidden/Lui.Scroll clipping since UI Toolkit has no z-index, so the only way to paint on
top is to render later in a higher layer.
Lui.Portal("my-overlay", Lui.Div("absolute inset-0 ...", panel))Lui.Portal returns an empty placeholder for the call site and registers the content for the
layer. Each portal needs a stable key for reconciliation and stacking order. The overlay layer
fills the panel, so portal content is positioned in panel space (absolute + inset-*, or anchored
to a trigger, see below). If you emit a portal from inside a Lui.Memo, it is preserved across
memo skips automatically; just make sure the overlay’s open/visible state is one of the memo’s
dependencies so it refreshes when it changes.
Anchored Overlays: Popup, Tooltip, Context Menu
LuiOverlay ships three overlays that float next to a trigger element. They render through
Lui.Portal (so they draw above everything and are never clipped), automatically flip above or
below depending on available room, and cap their height plus scroll internally so a long panel
stays on screen. Each takes a caller-owned LuiRef (declare it as a field, like a signal) that is
attached to the trigger and used to position the panel.
LuiOverlay.Popup: a controlled panel anchored to a trigger. Pass an open bool and a LuiRef;
it adds a click-outside backdrop that closes it (toggle off with closeOnClickOutside: false):
private readonly LuiRef _menuAnchor = new();
var trigger = Lui.Button(open.Value ? "Close" : "Open", "...", () => open.Value = !open.Value);
LuiOverlay.Popup(trigger, _menuAnchor, open.Value,
Lui.Div("flex flex-col gap-1",
Lui.Button("Profile", "...", () => { OpenProfile(); open.Value = false; }),
Lui.Button("Sign out", "...", () => { SignOut(); open.Value = false; })),
onClose: () => open.Value = false)LuiOverlay.Tooltip: shows a tip on hover. It wires the trigger’s hover via the OnHover
node hook for you; you supply a LuiRef and the visibility state:
LuiOverlay.Tooltip(
Lui.Div("...", Lui.Text("Hover me")),
_tipAnchor,
"Tooltips appear on hover and flip to stay on screen.",
visible.Value,
v => visible.Value = v)LuiOverlay.ContextMenu: opens a menu on right-click (wired through the OnContext node
hook), anchored to the trigger. Items are LuiMenuItem(label, onClick, role, disabled); choosing
one closes the menu, and a click-outside backdrop also closes it:
var items = new List<LuiMenuItem>
{
new LuiMenuItem("Inspect", Inspect),
new LuiMenuItem("Rename", Rename, disabled: true),
new LuiMenuItem("Delete", Delete, role: LuiModalActionRole.Danger)
};
LuiOverlay.ContextMenu(targetNode, _menuAnchor, open.Value, v => open.Value = v, items)Panels reposition when the trigger or panel changes size; they do not currently follow a trigger that scrolls under them (close the overlay on scroll if you need that). The click-outside backdrop blocks input to the rest of the UI while open, which matches normal menu behaviour.
Toasts
LuiOverlay.ToastHost renders a stack of dismissible notifications pinned to a screen corner. It
renders through Lui.Portal, so it is screen-global no matter where you place the call. You own the
toast list (add/remove); the host is presentational:
LuiOverlay.ToastHost(
toasts.Value, // IReadOnlyList<LuiToast>
LuiToastCorner.TopRight,
onDismiss: id => RemoveToast(id))Each LuiToast(id, message, title, tone) is keyed by its id and animates in. The host container is
non-blocking (pointer-none) so it never eats input outside the toast cards. Auto-dismiss is the
caller’s job — remove a toast from the list on a timer (e.g. from the component’s Update).