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 | Portaled to <body>, or into the panel or dialog that opened it; fixed inset-0 z-[100] | Confirmations, warnings, small forms and variant="sheet" side sheets |
FullscreenPanel | Portaled to <body> or the enclosing overlay layer, fixed inset-0 z-[100] | Full-page views and the dashboard settings window |
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
DialogContent variant="sheet" uses the same Dialog focus trap, Escape stack and scroll lock. It sits on the right at desktop widths and fills a phone screen; hideClose suppresses the built-in close button when the sheet header provides one. AlertDeck also accepts controlled selectedKey and onSelectedKeyChange; the dashboard shell's ConnectionAlertsContext.focusAlert(key) restores, selects, scrolls to and focuses a connection notice.
FullscreenPanel frame="window" renders the dashboard settings window as a card up to 1536 × 960 px, with 24 px desktop edge spacing. Below 768 px it fills the screen. The root remains a full-screen overlay layer, while the backdrop and card are children, so nested dialogs and wizards can cover the whole viewport. onBackdropClick runs only when pointerdown and click both land on the backdrop and the window was topmost at pointerdown. Nested FullscreenPanel and FullscreenWizard instances portal into the enclosing layer root; their focus traps count only their own layer's tab stops. Escape closes the topmost layer first, and the shared reference-counted scroll lock remains until the last layer closes.
DialogContent mobile="fullscreen" fills the screen below sm for forms, selections and stepped flows. Its header and footer stay visible while the body scrolls, and footer buttons stack with the primary action first. Confirmations and identity step-up use the default card treatment. Motion preferences reduce the phone entrance to a fade or make it instant.
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) |
z-[10000] | Toasts (ToastProvider, portaled to <body>), above modals and full-screen editors |
99999 | Tooltips (TooltipProvider) |
ToastProvider portals the toast stack to <body> after mount. An ancestor with its own z-index
creates a stacking context that could otherwise trap an inline toast behind a modal or full-screen
editor. The body portal keeps toasts above those surfaces and below tooltips.
Toast dismissal
Every success, info and error toast has a close (✕) button, including toasts with an action such as
Undo. The button sits above SeasonToastAccent so the seasonal decoration cannot cover it or
intercept clicks. Each toast dismisses automatically after five seconds. Its own countdown pauses while
the pointer is over that toast or it has keyboard focus, then resumes with the remaining time. Closing
one toast leaves the others' countdowns alone.
Clicking ✕ uses the same 150 ms exit fade as automatic dismissal; with Motion Off, the toast disappears immediately. A toast does not register with the Escape dismiss stack: Escape does not close it, even when its close button has focus. Escape remains available to the topmost dialog or panel underneath.
The stacking rule: nest dialogs inside the panel
DropdownMenu (the ⋯ menus) portals into the enclosing overlay layer — a Dialog or
FullscreenPanel root such as the settings window — and only falls back to <body>. Its z-50
then counts inside that layer, so a menu opened in a dialog or in the settings window stacks above
it instead of behind it.
Dialog and FullscreenPanel sit on the same z-[100]. On a tie the browser paints by DOM
order. A panel root is an overlay layer: a Dialog rendered in the panel's React subtree portals into
the panel root, so it is a DOM descendant and stacks above the panel. A Dialog rendered as a
sibling of a panel portals to <body> beside it and can be painted behind it, invisible:
// ✗ Wrong — the Dialog is a sibling of the panel and can land 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).
A box marked data-popover-boundary (POPOVER_BOUNDARY_ATTR from @lumio/ui) bounds the floating
lists opened inside it. The settings window card carries it, so a Select list in the window stays
within the card: it is at most as wide as the card, flips to the trigger's other edge or shifts
against the card's sides, and opens upwards when it does not fit below. Outside such a box the
viewport bounds the list. Rows truncate by default; SelectItem wrap lets a row with an explanation
under its label wrap instead (the member role picker).
The channel picker that ?account_select=true shows after a sign-in with several channels sits on
z-[110], above the modal layer. While it is up the dashboard shell keeps the settings window
closed, even with #settings… in the address; picking a channel reloads the same address without
the parameter, and the window opens on the chosen channel.
Why the Dialog root portals
DialogContent portals its fixed inset-0 root to <body> (or into the enclosing overlay layer, see
above). Rendered inline, the root would take on its parents' layout:
- a
space-y-*parent gives it amargin-bottom, so the backdrop stops short of the bottom edge and a strip of the page stays sharp and clickable; - an ancestor with
transform,filter,backdrop-filterorcontainbecomes its containing block, so the backdrop covers only that ancestor's box; - an ancestor with its own z-index (the app footer's
relative z-10) traps the dialog'sz-[100]in its stacking context, below the dashboard sidebar (sticky z-30) and the mobile header (fixed z-50).
Call sites therefore render a Dialog wherever its trigger lives; no local createPortal is needed.
shared/ui/__tests__/dialog-portal.test.tsx covers the body and in-panel cases;
apps/web/__tests__/components/cookie-manager-portal.test.tsx asserts the footer case
(footer.contains(dialog) === false).
Dialogs on a phone
A centred dialog taller than the viewport starts at the top edge and the backdrop scrolls (the root is
items-start overflow-y-auto, the panel centres with my-auto). Initial focus does not scroll, so a
dialog opens at its top. A dialog with a header and a footer band keeps both on screen: the panel is
flex max-h-full flex-col, header and footer are shrink-0, and the middle is
min-h-0 overflow-y-auto. The Settings/Security frame (HubDialogContent, HubDialogBody,
HubDialogFooter in apps/web/src/components/account-hub/hub-dialog.tsx) does this for every dialog
built on it; a <form> that wraps body and footer needs flex min-h-0 flex-col as well. Cap a dialog
with max-h-full or dvh units, not vh: in mobile Safari 90vh is taller than the visible area.
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.
The lock sets overflow: hidden on <html> only and never touches <body>. If both carry a
non-visible overflow, <body> becomes a scroll container of its own and every position: sticky
element sticks to it instead of the window: the dashboard sidebar would slide up by the page's scroll
offset whenever a dialog opens. Do not pin the body with position: fixed; top: -scrollY either; it
causes the same jump. While locked, <html> is padded by the width of the page scrollbar, so content
does not shift sideways on systems with classic scrollbars (nothing is added with overlay
scrollbars). Scrolling regions inside an overlay use overscroll-contain, so a touch scroll that
reaches their end does not move the page behind (the FullscreenPanel body region already does).
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.