EngineOverlay
Status: first-class Overlay UI is implemented and available through both schema nodes and direct React exports.
Built-ins:
dialog,drawer,popoverReact exports:
EngineDialog,EngineDrawer,EnginePopover
EngineOverlay is Next.js Engine's shared interaction layer for UI that visually sits above the normal document flow. It provides modal dialogs, edge drawers and anchored popovers without requiring every page to rebuild focus handling, portals, Escape behavior, stacking and positioning by hand.
The three primitives share one runtime, but each keeps a different interaction model:
Primitive | Best for | Modal by default | Anchored |
|---|---|---|---|
| confirms, editors, prompts, command surfaces | yes | no |
| settings, inspectors, filters, mobile sheets | yes | screen edge |
| menus, options, small contextual panels | no | yes |
The overlay components are lazy-loaded through the Engine registry. A page that never renders an overlay does not eagerly load the Overlay React component modules.
Mental model
Schema / JSX
↓
EngineDialog / EngineDrawer / EnginePopover
↓
shared EngineOverlay runtime
├── overlay stack
├── Escape ownership
├── focus discovery / focus trap
├── focus restoration
├── body scroll locking
└── popover collision positioning
↓
React portal
↓
document.body or portalTargetIdThis makes Overlay UI a primitive system. Higher-level patterns such as command palettes and context menus should normally be compositions built from these primitives rather than additional Engine node types.
Quick start
Dialog
{
type: "dialog",
props: {
triggerLabel: "Delete project",
title: "Delete project?",
description: "This cannot be undone.",
actions: [
{ label: "Cancel" },
{
label: "Delete",
variant: "danger",
onClick: "deleteProject",
},
],
},
}Named actions resolve through createPage({ handlers }):
export default createPage({
schema,
handlers: {
deleteProject: () => {
console.log("delete");
},
},
});Drawer
{
type: "drawer",
props: {
triggerLabel: "Settings",
title: "Project settings",
side: "right",
size: "26rem",
},
children: [
{ type: "text", props: { content: "Settings content" } },
],
}Popover
{
type: "popover",
props: {
triggerLabel: "Options",
placement: "bottom",
align: "start",
offset: 8,
},
children: [
{ type: "text", props: { content: "Profile" } },
{ type: "text", props: { content: "Settings" } },
],
}Popover positioning automatically tries the preferred side, flips to the opposite side when that side has more usable room, then clamps the panel inside the viewport.
Direct React use
"use client";
import { EngineDialog, EngineDrawer, EnginePopover } from "nextjs-engine";
export function OverlayExamples() {
return (
<>
<EngineDialog triggerLabel="Open dialog" title="Hello">
Dialog content
</EngineDialog>
<EngineDrawer triggerLabel="Open drawer" side="left">
Drawer content
</EngineDrawer>
<EnginePopover triggerLabel="Open popover" placement="top">
Popover content
</EnginePopover>
</>
);
}All three also accept normal Engine styling props through BaseNodeProps.
Shared props
These props are shared by Dialog, Drawer and Popover unless noted otherwise.
Prop | Type | Default / behavior |
|---|---|---|
|
| controlled open state |
|
|
|
| handler name or function | called with the new boolean state |
|
| custom trigger content |
|
| generated trigger text |
|
| class for generated trigger button |
|
| style for generated trigger button |
|
| disables generated trigger |
|
| accessible trigger label |
|
| visible heading + ARIA label source |
|
| visible description + ARIA description source |
|
| footer actions |
|
| Dialog/Drawer |
|
| accessible close-button label |
|
|
|
|
|
|
|
| selector searched inside the panel |
|
| transition ms, clamped to |
|
| portal element id; falls back to |
|
| fallback label when there is no visible title |
| Engine style | styles the panel |
|
| panel class |
| Engine CPROP | state styling for the panel |
|
| Dialog/Drawer |
If both trigger and triggerLabel are missing, no generated trigger button is rendered. That is useful when the overlay is controlled by external application state.
Controlled and uncontrolled state
Use defaultOpen when EngineOverlay should own the state:
<EngineDialog
defaultOpen
triggerLabel="Open"
title="Welcome"
/>Use open + onOpenChange when the application owns the state:
<EngineDialog
open={open}
onOpenChange={setOpen}
triggerLabel="Open"
/>Schema can point onOpenChange at a named Engine handler:
{
type: "drawer",
props: {
open: drawerOpen,
onOpenChange: "drawerChanged",
},
}The named handler receives the new boolean value.
Actions
props: {
actions: [
{ label: "Later", variant: "ghost" },
{ label: "Save", variant: "primary", onClick: "save" },
{ label: "Delete", variant: "danger", onClick: "remove" },
],
}Action variants:
primary
secondary
danger
ghostAn action closes the overlay after running unless close: false is set:
{
label: "Apply",
onClick: "applySettings",
close: false,
}Use disabled: true to disable an action.
Dialog
Dialog is the default primitive for modal interaction.
Prop | Default | Meaning |
|---|---|---|
|
|
|
|
| backdrop press closes |
|
| locks document scrolling while open |
|
| Tab stays inside the panel |
|
| animation duration in ms |
Use alertdialog for a decision that needs immediate user attention:
{
type: "dialog",
props: {
role: "alertdialog",
triggerLabel: "Remove account",
title: "Remove this account?",
description: "This action cannot be undone.",
},
}Drawer
Drawer uses the same modal behavior as Dialog but enters from a screen edge.
Prop | Default | Meaning |
|---|---|---|
|
|
|
|
| drawer width/height depending on side |
|
| backdrop press closes |
|
| locks document scrolling |
|
| keeps focus inside |
|
| animation duration in ms |
A mobile sheet is simply a bottom Drawer:
{
type: "drawer",
props: {
triggerLabel: "Filters",
title: "Filters",
side: "bottom",
size: "70vh",
},
}There is intentionally no separate sheet primitive because it would duplicate Drawer behavior.
Popover
Popover is anchored to its generated trigger and is non-modal by default.
Prop | Default | Meaning |
|---|---|---|
|
|
|
|
|
|
|
| trigger-to-panel distance |
|
| minimum viewport edge gap |
|
| outside pointer closes the top popover |
|
| panel min-width follows trigger width |
|
| focuses the first panel control |
|
| keeps Tab inside the popover |
|
|
|
|
| animation duration in ms |
The open Popover repositions when:
- the viewport resizes;
- the page or a scroll container scrolls;
- the trigger changes size;
- the panel changes size.
Resize/scroll updates are coalesced through requestAnimationFrame, and ResizeObserver exists only while the Popover is present.
Focus behavior
initialFocus accepts a CSS selector searched inside the panel:
<EngineDialog
triggerLabel="Edit project"
title="Edit project"
initialFocus="#project-name"
>
<input id="project-name" />
</EngineDialog>Focus selection order is:
initialFocus match
↓
first focusable descendant
↓
panel itselfWhen a modal closes, focus returns to its trigger when possible. If the trigger is unavailable, EngineOverlay falls back to the element that was focused before opening.
Dialog and Drawer trap Tab by default. Popover does not, because a normal contextual menu should not automatically behave like a modal dialog.
Overlay stacking
EngineOverlay maintains one shared stack. The latest registered overlay is considered the top overlay for keyboard and contextual outside-click handling.
This allows combinations such as:
Dialog
└── Popover
└── contextual controlsEscape is handled by the top overlay first. Body scroll locking is reference-counted, so nested modal overlays cannot accidentally unlock document scrolling while another modal still needs the lock.
Portals
Overlay panels portal to document.body by default. This prevents a parent element's overflow, transform or stacking context from clipping the overlay.
<EngineDialog portalTargetId="app-overlay-root" />When the requested target is missing, EngineOverlay safely falls back to document.body.
The portal runtime is client-side only; the trigger can still participate in normal SSR/hydration.
Styling
Normal Engine styling props style the panel. Shared visual defaults use CSS variables:
--e-overlay-bg
--e-overlay-color
--e-overlay-border
--e-overlay-shadow
--e-overlay-radius
--e-overlay-muted
--e-overlay-trigger-bg
--e-overlay-trigger-border
--e-overlay-action-bgExample:
<EngineDialog
triggerLabel="Open"
style={{
"--e-overlay-bg": "#07111f",
"--e-overlay-color": "#f8fafc",
borderRadius: 20,
} as React.CSSProperties}
/>triggerStyle and triggerClassName style the generated trigger. overlayStyle styles the Dialog/Drawer backdrop.
Reduced motion
When the browser reports:
@media (prefers-reduced-motion: reduce)EngineOverlay resolves its transition duration to zero. Functionality remains unchanged; only the decorative transition is removed.
Composition recipes
Do not create a new core primitive merely because a product pattern has a familiar name. Compose Overlay primitives with existing Engine components:
Command Palette = Dialog + Input + result list
Context Menu = Popover(role="menu") + menu actions
Dropdown Menu = Popover + Stack + Buttons
Account Menu = Popover + links/actions
Mobile Sheet = Drawer(side="bottom")
Inspector = Drawer(side="right")
Navigation Rail = Drawer(side="left")
Confirm Prompt = Dialog + actions
Edit Form = Dialog + EngineForm
Filter Panel = Drawer + EngineFormThis is the intended Overlay UI architecture: a small number of strong primitives with reusable compositions on top.
Performance
- Overlay React modules are registry split-points.
- Closed overlays do not keep portal DOM mounted after their exit duration.
- No persistent animation loop runs while overlays are idle.
- Dialog/Drawer share one reference-counted body-scroll lock.
- Popover positioning work is RAF-coalesced.
- Popover
ResizeObserverinstances exist only while the panel is present. prefers-reduced-motionavoids unnecessary animation work.
Low-level API
Advanced callers can use the positioning runtime directly:
import {
computePopoverPosition,
getFocusableElements,
isTopOverlay,
lockBodyScroll,
registerOverlay,
} from "nextjs-engine";computePopoverPosition() accepts trigger and panel rectangles plus viewport width/height, preferred placement, alignment, offset and viewport padding. It returns:
{
top: number,
left: number,
placement: "top" | "right" | "bottom" | "left"
}The other low-level helpers are exported mainly for advanced integrations. Normal application code should prefer EngineDialog, EngineDrawer and EnginePopover.
Troubleshooting
The overlay has no button
Provide triggerLabel or trigger. If neither is supplied, EngineOverlay assumes the open state is controlled externally and intentionally renders no generated trigger.
A Popover opens on another side
That is collision handling. The requested placement is a preference, not a promise. If the opposite side has more usable room, the Popover flips automatically.
Focus does not move to initialFocus
initialFocus must be a selector matching an element inside the overlay panel. If it does not match, the runtime uses the first focusable element or the panel itself.
Page scrolling is locked
Dialog and Drawer lock body scrolling by default. Set lockScroll: false only when the product interaction genuinely requires the background page to stay scrollable.
I need a command palette / context menu / sheet
Build it as a composition. Those are product patterns; Dialog, Popover and Drawer are the core interaction primitives.
CI coverage
The repository includes scripts/engine-overlay-smoke.js, and the main Engine CI runs it before TypeScript and the full Next.js integration build. The smoke test covers placement, flipping, viewport clamping and overlay-stack registration behavior.
That keeps the shared runtime protected independently from the React rendering layer.