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.
| Primitive | Renders | Use for |
|---|---|---|
Dialog / DialogContent | Inline (no portal), fixed inset-0 z-[100] | Confirmations, warnings, small forms |
FullscreenPanel | Portaled 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) |
FullscreenWizard | FullscreenPanel + the WizardSteps core (progress bar, Back/Next footer) | Create/edit flows with steps |
SetupWizard | Dialog + the same WizardSteps core | Stepped flows inside a dialog |
Layer scale
Stacking order, low to high:
| z-index | Layer |
|---|---|
z-40 | Dropdown/menu backdrops |
z-50 | Full-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
| Prop | Purpose |
|---|---|
title | Header title slot (the caller renders its own heading) |
actions | Header actions, right-aligned before the close button |
closeLabel | Required. Translated accessible name of the close (X) button |
onClose | Called by the X button and Escape |
showClose | Hide the X (Escape still calls onClose) |
children / scrollBody | Body content; scrolls inside the body region unless scrollBody={false} (the wizard manages its own scrolling) |
footer | Optional pinned footer row |
layers | Nested overlays (see the stacking rule) |
accentColor | 2-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) |
overlay | Decorative background slot behind all content (e.g. a platform gradient) |
ariaLabelledBy / ariaLabel | Turn 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.