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 / DialogContentPortaled 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
FullscreenPanelPortaled to <body> or the enclosing overlay layer, fixed inset-0 z-[100]Full-page views and the dashboard settings window
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​

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-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)
z-[10000]Toasts (ToastProvider, portaled to <body>), above modals and full-screen editors
99999Tooltips (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 a margin-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-filter or contain becomes 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's z-[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​

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.