Every centered dialog in the editor chrome, the dashboard and the marketing
site renders through Modal
(packages/sdk/src/chrome/floating/Modal.tsx).
It owns the backdrop, the enter/exit motion, Escape, the focus trap, scroll
lock, role="dialog" and the z-index, so those are fixed in one place.
When to use it
- Use it for anything that dims the page and sits in the middle of it: confirms, forms, pickers, previews, the command palette.
- Confirms: use
ConfirmDialog(packages/sdk/src/chrome/primitives/layout/ConfirmDialog.tsx), which is built onModal. There is no app-side copy. From a handler, call it throughuseConfirm()(useConfirm.tsx):const { ask, dialog } = useConfirm(), render{dialog}once, thenif (!(await ask({ title, message, confirmText }))) return;. - Never the browser's
confirm()/alert()/prompt(). Ask withuseConfirm(), report failures in the screen's error state or aCallout, and collect typed values with an input.tests/guards/nativeDialogs.guard.mjsfails on any. - Not for drawers / side sheets (
RailShell, the admin drawers; a dashboard record sheet isSettingsSheet), popovers and dropdowns (useAnchoredPopover), toasts, orCartDrawer(a customer-site component, not chrome).
tests/guards/modalShell.guard.mjs fails on a new .tsx under components/,
pages/ or packages/sdk/src/chrome/ that has fixed inset-0 together with
backdrop-blur or aria-modal. A drawer that legitimately trips it goes in
the guard's ALLOWED map with a reason.
Props
| Prop | Default | What it does |
|---|---|---|
isOpen | — | Show the dialog. Keep the component mounted and flip this to false so the exit plays; it unmounts itself after the transition. |
onClose | — | Called on Escape, backdrop click and the header close button. Put "can't close while sending" guards inside it. |
id | generated | Overlay-stack id (Escape order). Only needed when something else looks it up. |
title | — | With surface, renders the header bar. Also the accessible name. |
ariaLabel | — | Accessible name when there's no title or visible heading. |
ariaLabelledBy | — | Id of the heading inside children that names the dialog. Preferred when there is one. |
role | "dialog" | "alertdialog" for must-answer interruptions (save conflict). |
size | "md" | Card width with surface: sm md lg xl full. |
variant | "centered" | "sheet-bottom": full-width sheet that slides up from the bottom with a gap above it, so the page stays visible behind (the dashboard account sheet). "drawer-right" / "drawer-left" exist, but app drawers don't use them, and in the dashboard tests/guards/dashboardSheet.guard.mjs fails on them (use SettingsSheet). |
dismissable | true | Escape + backdrop dismiss. false for dialogs that must be answered. |
closeOnBackdrop | dismissable | Turn backdrop click off but keep Escape. |
zIndex | OVERLAY_Z_MODAL (70) | Stacking level — see below. |
surface | true | true: the shell draws the editor card (ph-modal-surface, size width, title header). false: the panel is a bare box and children (or className) draw the card. App dialogs use false. |
tintClassName | bg-base-100/75 | Backdrop tint, e.g. bg-black/50, bg-black/60 dark:bg-black/70. |
blur | 8 | Backdrop blur radius in px; 0 for none. |
className | — | Merged onto the panel (the card when surface). |
positionerClassName | — | Merged onto the full-screen flex box that places the panel, e.g. items-end sm:items-center for a bottom sheet on phones, items-start pt-[15vh] for a palette. |
children | — | The dialog content. |
Clicks on the panel never close it; clicks outside it reach the backdrop. A
panel that must span the row (so its card can change width) passes
className="pointer-events-none …" and gives the card pointer-events-auto,
so the empty sides still close on click.
Bottom sheets
Use variant="sheet-bottom" for settings you change and close (account,
billing, keys). Keep long multi-step flows, anything that redirects out and
back (OAuth, Stripe checkout), and big workspaces as real pages. Three rules
keep sheets from getting confusing:
- One sheet at a time. A link inside a sheet changes what the sheet shows, or goes to a real page. Never open a sheet over a sheet.
- The sheet is in the URL. The account sheet is
?account=<section>(useAccountSheet.ts). Refresh keeps it open, Back closes it, links can be shared. - Switching sections replaces the URL (
<Link replace shallow scroll={false}>), so Back closes the sheet in one step.
Marketing modals live in the URL
On the marketing site every modal a visitor opens on purpose is a query param, so the
phone's Back button closes it instead of leaving the page, a refresh keeps it open, and
the link can be shared. Use useUrlModal(name) (hooks/useUrlModal.ts):
- Open with a
<Link href={modal.hrefFor(value)} shallow scroll={false}>(cards, rows), ormodal.open(value)from a menu action or a button component that can't take an href. Never a localuseState(open). - Switch items inside it (prev / next) with
modal.set(value): a replace, so Back still closes the modal in one step. - Close with
modal.close(): it goes Back when this page opened the modal, and replaces the URL when the visitor landed with the param, so closing never leaves the site. - One host per param. A modal opened from many places is mounted once and every
trigger just sets the param:
?contact=general|dmcaand?connect=agentare hosted byMarketingSiteNav;?view=<slug>by the templates page.
A sub-dialog inside a modal (the template picker's company profile form) stays local
state. The dashboard's equivalent is useUrlSheet (see Bottom sheets above).
Backdrop rule — never blur inside an opacity fade
Chrome doesn't draw a backdrop-filter while the element, or any ancestor, is
partway through an opacity transition or animation. A fading blurred backdrop
shows a sharp page and then snaps to blurred when the fade ends — a visible
flash (found on the homepage contact dialog, 2026-10-15). Fading the blurred
element itself does the same.
Modal draws the backdrop as two layers: the tint fades its opacity and has
no blur; the blur layer stays at opacity 1 and animates its blur radius
(blur(0px) → blur(8px)). Never put backdrop-blur-* on an element that
has, or sits inside, an opacity transition — use Modal and its blur prop.
Motion is plain CSS transitions (no framer-motion — it's an optional peer of
the SDK). 250 ms, cubic-bezier(0.19, 1, 0.22, 1) for backdrop and panel.
prefers-reduced-motion: reduce turns the transition off.
Escape rule — works on every surface, fires once
The SDK's Escape dispatcher (ph.overlay.dismissTop) only exists inside the
editor. useOverlay alone therefore does nothing on the dashboard or marketing
pages. Modal does both:
- registers with
useOverlay, so inside the editor the dispatcher (adocumentlistener) closes it and callspreventDefault(); - listens on
windowitself, and closes only when the event is not alreadydefaultPreventedand it is the top of the overlay stack.
window hears the event after document, so inside the editor the dispatcher
always wins and the fallback skips — no double dismiss. Nested dialogs close
one at a time, innermost first. A widget inside a dialog that uses Escape for
itself (clearing a search field) should preventDefault() it.
z levels
From overlayZIndex.ts:
| Constant | Value | Use |
|---|---|---|
OVERLAY_Z_MODAL | 70 | Editor dialogs (page settings, font picker, design vars). |
OVERLAY_Z_MEDIA_MANAGER | 75 | Media manager. |
OVERLAY_Z_CRITICAL_MODAL | 98 | Editor dialogs over other editor dialogs (crop, media preview / edit). |
OVERLAY_Z_PAGE_SHEET | 1000 | Dashboard bottom sheets. A dialog opened from inside a sheet uses 1200 or above. |
OVERLAY_Z_PAGE_MODAL | 9997 | Dashboard and marketing dialogs, and ConfirmDialog everywhere. Above the marketing nav (z-70), dashboard chrome and the editor's floating panels (1100). |
A dialog moved to 70 on a marketing page renders under the nav — page
dialogs use OVERLAY_Z_PAGE_MODAL or above. A dialog that opens over another
page dialog goes a step higher (OVERLAY_Z_PAGE_MODAL + 2, as the contact
dialog does). The backdrop sits one below the panel (zIndex - 1).
Tests
pnpm test:modal (tests/modal.test.tsx) runs
Modal in jsdom: Escape with no dispatcher, no double dismiss with one, nested
order, backdrop vs panel clicks, dismissable={false}, the two-layer backdrop
and exit-before-unmount.