Modal and confirm

The one dialog shell, and a confirm you can await from a click handler.

On this page

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.

TypeScript
import { Modal } from "@pagehub/sdk/chrome/floating/Modal"; // app code
import { OVERLAY_Z_PAGE_MODAL } from "@pagehub/sdk/chrome/popovers/overlayZIndex";

<Modal
  isOpen={open}
  onClose={() => setOpen(false)}
  ariaLabelledBy="invite-title"
  zIndex={OVERLAY_Z_PAGE_MODAL}
  surface={false}
  className="bg-base-100 w-full max-w-md rounded-xl border shadow-xl"
>
  <h2 id="invite-title">Invite a teammate</h2>
  …
</Modal>

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 on Modal. There is no app-side copy. From a handler, call it through useConfirm() (useConfirm.tsx): const { ask, dialog } = useConfirm(), render {dialog} once, then if (!(await ask({ title, message, confirmText }))) return;.
  • Never the browser's confirm() / alert() / prompt(). Ask with useConfirm(), report failures in the screen's error state or a Callout, and collect typed values with an input. tests/guards/nativeDialogs.guard.mjs fails on any.
  • Not for drawers / side sheets (RailShell, the admin drawers; a dashboard record sheet is SettingsSheet), popovers and dropdowns (useAnchoredPopover), toasts, or CartDrawer (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

PropDefaultWhat 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.
idgeneratedOverlay-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).
dismissabletrueEscape + backdrop dismiss. false for dialogs that must be answered.
closeOnBackdropdismissableTurn backdrop click off but keep Escape.
zIndexOVERLAY_Z_MODAL (70)Stacking level — see below.
surfacetruetrue: 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.
tintClassNamebg-base-100/75Backdrop tint, e.g. bg-black/50, bg-black/60 dark:bg-black/70.
blur8Backdrop 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), or modal.open(value) from a menu action or a button component that can't take an href. Never a local useState(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|dmca and ?connect=agent are hosted by MarketingSiteNav; ?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:

  1. registers with useOverlay, so inside the editor the dispatcher (a document listener) closes it and calls preventDefault();
  2. listens on window itself, and closes only when the event is not already defaultPrevented and 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:

ConstantValueUse
OVERLAY_Z_MODAL70Editor dialogs (page settings, font picker, design vars).
OVERLAY_Z_MEDIA_MANAGER75Media manager.
OVERLAY_Z_CRITICAL_MODAL98Editor dialogs over other editor dialogs (crop, media preview / edit).
OVERLAY_Z_PAGE_SHEET1000Dashboard bottom sheets. A dialog opened from inside a sheet uses 1200 or above.
OVERLAY_Z_PAGE_MODAL9997Dashboard 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.