Skip to main content

Frontend Overlays: Dialogs and Full-Screen Panels

The @lumio/ui design system (shared/ui) has two modal surfaces. This page covers how they stack, how Escape and scroll locking compose, and the one rule that keeps a nested dialog visible.

PrimitiveRendersUse for
Dialog / DialogContentInline (no portal), fixed inset-0 z-[100]Confirmations, warnings, small forms
FullscreenPanelPortaled to <body> after mount, fixed inset-0 z-[100]Full-page views that are not a stepped flow (e.g. the web app's user info card)
FullscreenWizardFullscreenPanel + the WizardSteps core (progress bar, Back/Next footer)Create/edit flows with steps
SetupWizardDialog + the same WizardSteps coreStepped flows inside a dialog

Layer scale​

Stacking order, low to high:

z-indexLayer
z-40Dropdown/menu backdrops
z-50Full-screen side panels and inline dropdowns (popout settings, chat-shell menus)
z-[100]Modal layer: Dialog, FullscreenPanel, FullscreenWizard, editor modals
z-[9999]Popovers that must float above their own dialog (ColorPicker)

The stacking rule: nest dialogs inside the panel​

Dialog and FullscreenPanel sit on the same z-[100]. On a tie the browser paints by DOM order, and the panel's body portal always comes later in the DOM than an inline Dialog. So a Dialog rendered as a sibling of a panel is painted behind it and is invisible:

// ✗ Wrong — the Dialog is a sibling of the body-portaled panel and lands behind it.
<>
<FullscreenPanel …>{content}</FullscreenPanel>
<Dialog open={confirmOpen} …>…</Dialog>
</>

Render every overlay the panel's content opens inside the panel. The layers slot exists for this: its children render last inside the portal root, so they stack above the panel content:

// ✓ Right — the Dialog is a DOM descendant of the panel root.
<FullscreenPanel
title={<h2 id={titleId}>{name}</h2>}
ariaLabelledBy={titleId}
closeLabel={tc("close")}
onClose={onClose}
layers={
<>
{pending && <Dialog open onOpenChange={() => setPending(null)}>…</Dialog>}
<LinkWarningDialog url={pendingLink} onClose={() => setPendingLink(null)} />
</>
}
>
{content}
</FullscreenPanel>

Cover it with a test that asserts DOM descendancy (panel.contains(dialog) where panel = document.querySelector("[data-fullscreen-panel]")), like apps/web/__tests__/components/info-user-modal-layers.test.tsx does for the user info card.

Absolutely positioned menus are the other trap: inside a scrolling region (overflow-y-auto) they are clipped at its edge. Prefer an inline disclosure (the user card's Timeout durations), or a component that portals itself (Select, DropdownMenu).

Escape: only the topmost layer closes​

Every overlay registers on one shared, ordered dismiss stack (useEscapeDismiss in shared/ui/src/escape-dismiss.ts), driven by a single capture-phase listener. Escape runs only the topmost layer's handler and is consumed. So with a confirmation open on a panel, the first Escape closes the confirmation and the second closes the panel. Never add an ad-hoc keydown Escape listener inside an overlay. Register with useEscapeDismiss(onDismiss, active) instead.

Scroll lock​

Dialog and FullscreenPanel share one reference-counted page scroll lock (useScrollLock in shared/ui/src/scroll-lock.ts). The page is locked while at least one holder is open, so a nested Dialog closing, or a closed Dialog mounting, never releases a panel's lock, whatever order React runs the cleanups in.

FullscreenPanel API​

PropPurpose
titleHeader title slot (the caller renders its own heading)
actionsHeader actions, right-aligned before the close button
closeLabelRequired. Translated accessible name of the close (X) button
onCloseCalled by the X button and Escape
showCloseHide the X (Escape still calls onClose)
children / scrollBodyBody content; scrolls inside the body region unless scrollBody={false} (the wizard manages its own scrolling)
footerOptional pinned footer row
layersNested overlays (see the stacking rule)
accentColor2-px accent line along the top edge (e.g. a platform colour)
backdrop"opaque" (default, bg-background) or "blur" (bg-background/85 + backdrop-blur-md)
overlayDecorative background slot behind all content (e.g. a platform gradient)
ariaLabelledBy / ariaLabelTurn on dialog semantics: role="dialog", aria-modal, focus moved in on open, Tab trapped, focus restored on close

There is no open prop. The caller mounts the panel only while it should be visible (if (!open) return null;), so its scroll lock and Escape layer live for exactly one open lifetime. FullscreenWizard follows the same rule.