@lattice-ui/react-dialogStable directionimport Dialogdepends on runtime, focus, layer, motionDialog is the primitive for any surface that should take over the screen: confirmations, forms, settings panels, and store windows. It coordinates open state, focus, dismissal, and exit motion so your component only has to render the frame and its contents.
Reach for Dialog when a surface needs to be modal (block interaction behind it), restore focus to whatever opened it, and dismiss predictably — by an explicit close, an outside interaction, or a controlled state change.
Preview
The component running live in the browser — the same @rbxts/react tree Roblox renders, fully interactive.
import { Dialog } from "@lattice-ui/react-dialog";import { React } from "@lattice-ui/react-runtime";import { mergeGuiProps, Text, useTheme } from "@lattice-ui/react-style";import { buttonRecipe, panelRecipe } from "../../../playground/src/client/theme/recipes";
function DialogExample() { const { theme } = useTheme();
const fields: Array<{ label: string; value: string }> = [ { label: "Name", value: "Pedro Duarte" }, { label: "Username", value: "@peduarte" }, ];
return ( <Dialog.Root> <Dialog.Trigger asChild> <textbutton {...(mergeGuiProps(buttonRecipe({ intent: "surface", size: "md" }, theme), { Size: UDim2.fromOffset(150, 40), Text: "Edit profile", TextSize: theme.typography.labelSm.textSize, }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> </textbutton> </Dialog.Trigger>
<Dialog.Portal> <Dialog.Overlay /> <Dialog.Content> <frame {...(mergeGuiProps(panelRecipe({ tone: "elevated" }, theme), { AnchorPoint: new Vector2(0.5, 0.5), Position: UDim2.fromScale(0.5, 0.5), Size: UDim2.fromOffset(420, 238), ZIndex: 10, }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.lg)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[24])} PaddingLeft={new UDim(0, theme.space[24])} PaddingRight={new UDim(0, theme.space[24])} PaddingTop={new UDim(0, theme.space[24])} /> <uilistlayout FillDirection={Enum.FillDirection.Vertical} Padding={new UDim(0, theme.space[12])} />
<frame BackgroundTransparency={1} LayoutOrder={0} Size={UDim2.fromOffset(372, 40)} ZIndex={11}> <Text BackgroundTransparency={1} Font={Enum.Font.GothamBold} Size={UDim2.fromOffset(372, 20)} Text="Edit profile" TextColor3={theme.colors.textPrimary} TextSize={theme.typography.bodyMd.textSize} TextXAlignment={Enum.TextXAlignment.Left} ZIndex={11} /> <Text BackgroundTransparency={1} Position={UDim2.fromOffset(0, 24)} Size={UDim2.fromOffset(372, 16)} Text="Make changes to your profile here." TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} ZIndex={11} /> </frame>
{fields.map((field, index) => ( <frame BackgroundTransparency={1} key={field.label} LayoutOrder={index + 1} Size={UDim2.fromOffset(372, 38)} ZIndex={11} > <Text BackgroundTransparency={1} Font={Enum.Font.GothamMedium} Size={UDim2.fromOffset(90, 38)} Text={field.label} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} ZIndex={11} /> <frame BackgroundColor3={theme.colors.surface} BorderSizePixel={0} Position={UDim2.fromOffset(100, 0)} Size={UDim2.fromOffset(272, 38)} ZIndex={11} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingLeft={new UDim(0, theme.space[12])} /> <Text BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text={field.value} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} ZIndex={12} /> </frame> </frame> ))}
<frame BackgroundTransparency={1} LayoutOrder={3} Size={UDim2.fromOffset(372, 38)} ZIndex={11}> <uilistlayout FillDirection={Enum.FillDirection.Horizontal} HorizontalAlignment={Enum.HorizontalAlignment.Right} Padding={new UDim(0, theme.space[8])} /> <Dialog.Close asChild> <textbutton {...(mergeGuiProps(buttonRecipe({ intent: "surface", size: "sm" }, theme), { LayoutOrder: 0, Size: UDim2.fromOffset(90, 38), Text: "Cancel", TextSize: theme.typography.labelSm.textSize, ZIndex: 11, }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> <uistroke Color={theme.colors.border} Thickness={1} /> </textbutton> </Dialog.Close> <Dialog.Close asChild> <textbutton {...(mergeGuiProps(buttonRecipe({ intent: "primary", size: "sm" }, theme), { LayoutOrder: 1, Size: UDim2.fromOffset(130, 38), Text: "Save changes", TextSize: theme.typography.labelSm.textSize, ZIndex: 11, }) as Record<string, unknown>)} > <uicorner CornerRadius={new UDim(0, theme.radius.md)} /> </textbutton> </Dialog.Close> </frame> </frame> </Dialog.Content> </Dialog.Portal> </Dialog.Root> );}Import
import { Dialog } from "@lattice-ui/react-dialog";Anatomy
Compose the full set of parts. Trigger, Overlay, and Close are optional depending on how you drive the dialog, but Root, Portal, and Content form the minimum useful surface.
Dialog anatomy
<Dialog.Root> <Dialog.Trigger /> <Dialog.Portal> <Dialog.Overlay /> <Dialog.Content /> </Dialog.Portal></Dialog.Root>| Part | Required | Responsibility |
|---|---|---|
Dialog.Root | yes | Owns open state and shares it with every part through context. |
Dialog.Trigger | no | A button that opens the dialog and is the default focus-restore target. |
Dialog.Portal | yes | Renders the surface into a ScreenGui outside the local tree. |
Dialog.Overlay | no | A full-screen backdrop behind the content that closes the dialog on press. |
Dialog.Content | yes | The focus-trapped, dismissable surface. |
Dialog.Close | no | A button that closes the dialog from inside the content. |
Examples
Basic usage
Uncontrolled state seeded with defaultOpen, a trigger that opens it, a dimmed overlay, and a close button inside the content.
Dialog.Overlay covers the screen and dismisses the dialog on press whether or not you style it — but it draws nothing until you give it a color, so pass BackgroundColor3 and BackgroundTransparency for a visible dim.
import { Dialog } from "@lattice-ui/react-dialog";
export function QuestDialog() { return ( <Dialog.Root defaultOpen={false}> <Dialog.Trigger asChild> <textbutton Size={UDim2.fromOffset(140, 38)} Text="View quest" /> </Dialog.Trigger>
<Dialog.Portal> <Dialog.Overlay BackgroundColor3={Color3.fromRGB(0, 0, 0)} BackgroundTransparency={0.5} /> <Dialog.Content> <frame AnchorPoint={new Vector2(0.5, 0.5)} BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromScale(0.5, 0.5)} Size={UDim2.fromOffset(320, 180)} > <uilistlayout Padding={new UDim(0, 12)} SortOrder={Enum.SortOrder.LayoutOrder} /> <uipadding PaddingLeft={new UDim(0, 16)} PaddingTop={new UDim(0, 16)} />
<textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(288, 40)} Text="Retrieve the lost relic from the sunken vault" TextColor3={Color3.fromRGB(240, 244, 250)} TextWrapped={true} TextXAlignment={Enum.TextXAlignment.Left} /> <Dialog.Close asChild> <textbutton BackgroundColor3={Color3.fromRGB(88, 142, 255)} Size={UDim2.fromOffset(100, 34)} Text="Done" TextColor3={Color3.fromRGB(240, 244, 250)} /> </Dialog.Close> </frame> </Dialog.Content> </Dialog.Portal> </Dialog.Root> );}Controlled confirmation from a game event
Pass open and onOpenChange when something other than a trigger drives the dialog — a server event, a touched part, a timer. There is no Dialog.Trigger here, which changes focus restoration: a trigger focuses itself the moment it opens the dialog, so the focus scope has a reliable snapshot to restore. Opened from a game event, there is usually nothing meaningful focused at open time, so opt out with restoreFocus={false} (or focus a specific element yourself when the dialog closes).
Note that an outside press still closes the dialog through onOpenChange, so treat open becoming false — not just the buttons — as the “dismissed” signal.
import { useEffect, useState } from "@rbxts/react";import { Dialog } from "@lattice-ui/react-dialog";
export function TradeRequestDialog(props: { requestFrom?: string; onRespond: (accepted: boolean) => void;}) { const [open, setOpen] = useState(false);
useEffect(() => { if (props.requestFrom !== undefined) { setOpen(true); } }, [props.requestFrom]);
const respond = (accepted: boolean) => { props.onRespond(accepted); setOpen(false); };
return ( <Dialog.Root open={open} onOpenChange={setOpen}> <Dialog.Portal> <Dialog.Overlay BackgroundColor3={Color3.fromRGB(0, 0, 0)} BackgroundTransparency={0.5} /> <Dialog.Content restoreFocus={false}> <frame AnchorPoint={new Vector2(0.5, 0.5)} BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromScale(0.5, 0.5)} Size={UDim2.fromOffset(300, 150)} > <uilistlayout Padding={new UDim(0, 12)} SortOrder={Enum.SortOrder.LayoutOrder} /> <uipadding PaddingLeft={new UDim(0, 16)} PaddingTop={new UDim(0, 16)} />
<textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(268, 24)} Text={`${props.requestFrom} wants to trade`} TextColor3={Color3.fromRGB(240, 244, 250)} TextXAlignment={Enum.TextXAlignment.Left} /> <textbutton BackgroundColor3={Color3.fromRGB(88, 142, 255)} Event={{ Activated: () => respond(true) }} Size={UDim2.fromOffset(120, 32)} Text="Accept" TextColor3={Color3.fromRGB(240, 244, 250)} /> <textbutton BackgroundColor3={Color3.fromRGB(59, 66, 84)} Event={{ Activated: () => respond(false) }} Size={UDim2.fromOffset(120, 32)} Text="Decline" TextColor3={Color3.fromRGB(240, 244, 250)} /> </frame> </Dialog.Content> </Dialog.Portal> </Dialog.Root> );}Custom overlay and content transition
Use asChild on Dialog.Overlay when you need a different element class for the backdrop, and pass transition on Dialog.Content for the entrance. Two things to design around: the overlay runs no motion of its own and owns no color, so both the dim and any fade are yours; and it closes the dialog on press, so keep the child an activatable button class. The content’s transition is used as-is (see Motion and presence) — here a taller 24px rise.
import { Dialog } from "@lattice-ui/react-dialog";import { motionTargets, type PresenceMotionConfig } from "@lattice-ui/react-motion";
const STORE_REVEAL: PresenceMotionConfig = { target: motionTargets.offsetWrapper("store reveal"), initial: { Position: UDim2.fromOffset(0, 24) }, reveal: { values: { Position: UDim2.fromOffset(0, 0) }, intent: { duration: 0.2, tempo: "swift", tone: "calm" }, }, exit: { values: { Position: UDim2.fromOffset(0, 24) }, intent: { duration: 0.16, tempo: "swift", tone: "calm" }, },};
export function StoreDialog() { return ( <Dialog.Root defaultOpen={false}> <Dialog.Trigger asChild> <textbutton Size={UDim2.fromOffset(140, 38)} Text="Open store" /> </Dialog.Trigger>
<Dialog.Portal> <Dialog.Overlay asChild> <textbutton AutoButtonColor={false} BackgroundColor3={Color3.fromRGB(16, 10, 32)} BorderSizePixel={0} Size={UDim2.fromScale(1, 1)} Text="" /> </Dialog.Overlay>
<Dialog.Content transition={STORE_REVEAL}> <frame AnchorPoint={new Vector2(0.5, 0.5)} BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromScale(0.5, 0.5)} Size={UDim2.fromOffset(420, 280)} > <uicorner CornerRadius={new UDim(0, 10)} /> <textlabel BackgroundTransparency={1} Position={UDim2.fromOffset(16, 12)} Size={UDim2.fromOffset(388, 28)} Text="Item shop" TextColor3={Color3.fromRGB(240, 244, 250)} TextXAlignment={Enum.TextXAlignment.Left} /> <Dialog.Close asChild> <textbutton AnchorPoint={new Vector2(1, 0)} Position={new UDim2(1, -12, 0, 12)} Size={UDim2.fromOffset(28, 28)} Text="X" TextColor3={Color3.fromRGB(240, 244, 250)} /> </Dialog.Close> </frame> </Dialog.Content> </Dialog.Portal> </Dialog.Root> );}Non-modal panel that vetoes dismissal
modal={false} removes the full-screen input sink, so the game world and other UI behind the panel stay interactive. Outside presses still route to the dialog and would dismiss it — modality controls blocking, not dismissal — so a persistent panel also has to veto the close by calling event.preventDefault() in onInteractOutside. Dropping trapFocus lets gamepad selection leave the panel too. The result closes only through its own button.
import { useState } from "@rbxts/react";import { Dialog } from "@lattice-ui/react-dialog";
export function CraftingPanel() { const [open, setOpen] = useState(false);
return ( <Dialog.Root open={open} onOpenChange={setOpen} modal={false}> <Dialog.Trigger asChild> <textbutton Size={UDim2.fromOffset(140, 38)} Text="Crafting" /> </Dialog.Trigger>
<Dialog.Portal> <Dialog.Content trapFocus={false} onInteractOutside={(event) => event.preventDefault()} > <frame AnchorPoint={new Vector2(1, 0.5)} BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={new UDim2(1, -16, 0.5, 0)} Size={UDim2.fromOffset(260, 340)} > <uilistlayout Padding={new UDim(0, 10)} SortOrder={Enum.SortOrder.LayoutOrder} /> <uipadding PaddingLeft={new UDim(0, 12)} PaddingTop={new UDim(0, 12)} />
<textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(236, 24)} Text="Crafting" TextColor3={Color3.fromRGB(240, 244, 250)} TextXAlignment={Enum.TextXAlignment.Left} /> <Dialog.Close asChild> <textbutton BackgroundColor3={Color3.fromRGB(59, 66, 84)} Size={UDim2.fromOffset(100, 32)} Text="Close" TextColor3={Color3.fromRGB(240, 244, 250)} /> </Dialog.Close> </frame> </Dialog.Content> </Dialog.Portal> </Dialog.Root> );}There is no overlay here on purpose: a backdrop would read as modal, and the default overlay would dismiss on press anyway.
Stacked dialogs with display order
Each Dialog.Content mounts its own layered ScreenGui whose DisplayOrder is displayOrderBase plus a global mount counter, so a later-opened dialog already lands above an earlier one at the same base. Explicit displayOrderBase bands make the ordering deterministic against your other layered UI — here the settings dialog sits in the 1000 band and its destructive confirmation in the 2000 band. Outside presses always go to the top-most open layer, so pressing the settings surface while the confirmation is up dismisses only the confirmation.
import { useState } from "@rbxts/react";import { Dialog } from "@lattice-ui/react-dialog";
export function ResetSettingsDialog() { const [settingsOpen, setSettingsOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
return ( <Dialog.Root open={settingsOpen} onOpenChange={setSettingsOpen}> <Dialog.Trigger asChild> <textbutton Size={UDim2.fromOffset(140, 38)} Text="Settings" /> </Dialog.Trigger>
<Dialog.Portal displayOrderBase={1000}> <Dialog.Overlay BackgroundColor3={Color3.fromRGB(0, 0, 0)} BackgroundTransparency={0.5} /> <Dialog.Content> <frame AnchorPoint={new Vector2(0.5, 0.5)} BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromScale(0.5, 0.5)} Size={UDim2.fromOffset(360, 220)} > <uilistlayout Padding={new UDim(0, 12)} SortOrder={Enum.SortOrder.LayoutOrder} /> <uipadding PaddingLeft={new UDim(0, 16)} PaddingTop={new UDim(0, 16)} />
<textbutton BackgroundColor3={Color3.fromRGB(120, 52, 52)} Event={{ Activated: () => setConfirmOpen(true) }} Size={UDim2.fromOffset(160, 34)} Text="Reset progress" TextColor3={Color3.fromRGB(240, 244, 250)} /> <Dialog.Close asChild> <textbutton BackgroundColor3={Color3.fromRGB(59, 66, 84)} Size={UDim2.fromOffset(100, 34)} Text="Close" TextColor3={Color3.fromRGB(240, 244, 250)} /> </Dialog.Close> </frame> </Dialog.Content> </Dialog.Portal>
<Dialog.Root open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog.Portal displayOrderBase={2000}> <Dialog.Overlay BackgroundColor3={Color3.fromRGB(0, 0, 0)} BackgroundTransparency={0.5} /> <Dialog.Content> <frame AnchorPoint={new Vector2(0.5, 0.5)} BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromScale(0.5, 0.5)} Size={UDim2.fromOffset(280, 140)} > <uilistlayout Padding={new UDim(0, 10)} SortOrder={Enum.SortOrder.LayoutOrder} /> <uipadding PaddingLeft={new UDim(0, 14)} PaddingTop={new UDim(0, 14)} />
<textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(252, 24)} Text="Reset all progress? This cannot be undone." TextColor3={Color3.fromRGB(240, 244, 250)} TextWrapped={true} TextXAlignment={Enum.TextXAlignment.Left} /> <textbutton BackgroundColor3={Color3.fromRGB(120, 52, 52)} Event={{ Activated: () => { setConfirmOpen(false); setSettingsOpen(false); }, }} Size={UDim2.fromOffset(100, 32)} Text="Reset" TextColor3={Color3.fromRGB(240, 244, 250)} /> </frame> </Dialog.Content> </Dialog.Portal> </Dialog.Root> </Dialog.Root> );}The confirmation is a controlled sibling dialog rather than a nested Dialog.Trigger, so it survives its own open/close cycle without being tied to the settings content tree.
Exit animation with forceMount
Without forceMount, Dialog.Content sits inside a presence wrapper that unmounts it after the exit motion reports completion, with a short fallback window as a safety net — long, expressive exits risk being cut off by that fallback. forceMount bypasses the presence wrapper entirely: the content stays mounted while closed (hidden once the exit finishes), the full exit intent always plays, and child state such as scroll position or TextBox contents survives between opens.
import { Dialog } from "@lattice-ui/react-dialog";import { motionTargets, type PresenceMotionConfig } from "@lattice-ui/react-motion";
const RESULTS_TRANSITION: PresenceMotionConfig = { target: motionTargets.offsetWrapper("results reveal"), initial: { Position: UDim2.fromOffset(0, 16) }, reveal: { values: { Position: UDim2.fromOffset(0, 0) }, intent: { duration: 0.35, tempo: "gentle", tone: "expressive" }, }, exit: { values: { Position: UDim2.fromOffset(0, 16) }, intent: { duration: 0.28, tempo: "gentle", tone: "expressive" }, },};
export function MatchResultsDialog(props: { open: boolean; onOpenChange: (open: boolean) => void;}) { return ( <Dialog.Root open={props.open} onOpenChange={props.onOpenChange}> <Dialog.Portal> <Dialog.Overlay BackgroundColor3={Color3.fromRGB(0, 0, 0)} BackgroundTransparency={0.5} /> <Dialog.Content forceMount transition={RESULTS_TRANSITION} restoreFocus={false}> <frame AnchorPoint={new Vector2(0.5, 0.5)} BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromScale(0.5, 0.5)} Size={UDim2.fromOffset(340, 200)} > <uilistlayout Padding={new UDim(0, 12)} SortOrder={Enum.SortOrder.LayoutOrder} /> <uipadding PaddingLeft={new UDim(0, 16)} PaddingTop={new UDim(0, 16)} />
<textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(308, 32)} Text="Victory!" TextColor3={Color3.fromRGB(240, 244, 250)} TextSize={24} TextXAlignment={Enum.TextXAlignment.Left} /> <Dialog.Close asChild> <textbutton BackgroundColor3={Color3.fromRGB(88, 142, 255)} Size={UDim2.fromOffset(120, 34)} Text="Continue" TextColor3={Color3.fromRGB(240, 244, 250)} /> </Dialog.Close> </frame> </Dialog.Content> </Dialog.Portal> </Dialog.Root> );}A force-mounted dialog that has never opened renders with its exit values pre-applied, so it stays invisible until the first reveal.
How it behaves
Open state
Dialog.Root is controllable. Pass open and onOpenChange to control it, or defaultOpen to run uncontrolled (defaulting to closed). Every path to a state change — trigger activation, close button, overlay press, outside-press dismissal — goes through the same setOpen, so onOpenChange sees all of them and controlled and uncontrolled usage behave identically.
Focus and selection
Dialog.Content wraps its children in a focus scope. By default it traps focus (trapFocus defaults to true) so gamepad and selection movement stay inside the surface while it is open, and restores focus (restoreFocus defaults to true) to whatever was focused just before it opened.
Dialog.Trigger is what makes restoration reliable: it registers itself as a focus node and focuses itself in the same activation that opens the dialog, so the scope’s restore snapshot points at the trigger. When you open a dialog without a trigger — controlled state driven by a game event — there may be nothing focused at open time; either pass restoreFocus={false} or move focus yourself after closing.
Dismissal and layering
Dialog.Content registers on a global dismissable-layer stack. An outside pointer press (mouse button or touch, ignoring input the engine already processed) is routed to the top-most open layer only: onPointerDownOutside fires first, then onInteractOutside for the same press, and then the dialog closes unless either handler called event.preventDefault(). This is how stacked dialogs behave sanely — a press on a lower dialog dismisses only the top one.
“Outside” is measured against the first direct host element you render inside Dialog.Content (your panel frame); additional top-level host children also count as inside. Presses within those bounds never trigger dismissal. Under asChild the same rule applies one level down, against the first host child of the element you supplied.
modal (default true) controls blocking, not dismissal: when modal, a full-screen input sink behind the content swallows interaction with everything underneath. With modal={false} the world stays interactive, but outside presses still dismiss unless you veto them.
Motion and presence
Dialog.Content renders your children inside a full-screen Frame, but runs no motion of its own. Pass a transition to animate it; the config you pass is used as-is, with nothing underneath to merge with. What the transition can move is the host — animate Position and the whole surface slides:
import { motionTargets, type PresenceMotionConfig } from "@lattice-ui/react-motion";
const RISE: PresenceMotionConfig = { target: motionTargets.offsetWrapper("dialog rise"), initial: { Position: UDim2.fromOffset(0, 8) }, reveal: { values: { Position: UDim2.fromOffset(0, 0) }, intent: { duration: 0.12, tempo: "swift", tone: "calm" }, }, exit: { values: { Position: UDim2.fromOffset(0, 8) }, intent: { duration: 0.096, tempo: "swift", tone: "calm" }, },};
<Dialog.Content transition={RISE}> {/* your panel */}</Dialog.Content>Fading a dialog
The default host is a plain Frame spanning the whole layer, and a Frame has no property that fades its descendants — BackgroundTransparency on the host fades the host’s own background, which covers the screen. Animating it through transition fills the screen with a rectangle instead of fading your panel. So either keep the content transition to Position and put the fade where the pixels are:
- Fade the dim.
createOverlayFadeRecipe()on an element insideDialog.Overlaycovers most of what a dialog reveal reads as. - Fade your own elements. Animate
BackgroundTransparencyon your panel frame andTextTransparency/ImageTransparencyon the children that need it, driven by the sameopenstate you pass toDialog.Root. Keep those durations at or under the content transition’s exit duration, or presence unmounts the tree before your fade finishes.
— or hand the dialog a canvasgroup with asChild. Your element becomes the motion host, so createCanvasGroupRevealRecipe() fades the whole subtree as one composited layer, the way the primitive used to before it stopped rendering a CanvasGroup for every dialog:
import { createCanvasGroupRevealRecipe } from "@lattice-ui/react-motion";
<Dialog.Content asChild transition={createCanvasGroupRevealRecipe()}> <canvasgroup> <frame AnchorPoint={new Vector2(0.5, 0.5)} BackgroundColor3={Color3.fromRGB(24, 26, 32)} Position={UDim2.fromScale(0.5, 0.5)} Size={UDim2.fromOffset(320, 180)} /> </canvasgroup></Dialog.Content>That buys an offscreen render target the size of the screen, which is exactly why it is opt-in rather than the default.
Outside presses are measured against the first direct host element inside Dialog.Content. With asChild your element is the host and spans the layer, so the boundary becomes its first host child instead — the panel above. Keep your panel as that first child, exactly as you would without asChild.
Presence timing is independent of that: with or without a transition, the content stays mounted until its exit resolves, so an exit animation is never cut off. forceMount on either part keeps it mounted while closed and lets long exits run outside the presence wrapper’s bounded unmount window.
Dialog.Overlay exposes no transition prop — an unstyled overlay has nothing to fade. It still owns presence timing. To animate a dim, render an element inside the overlay (or pass asChild) and animate that element yourself; createOverlayFadeRecipe() describes the fade the primitive used to run.
Dialog.Overlay renders a fully transparent full-screen textbutton. It still covers the screen and still swallows presses — which is how press-to-close and modal blocking work — but it draws nothing until you give it a color. Pass BackgroundColor3 and BackgroundTransparency directly; it also renders children, so a uigradient or nested frame works too.
Outside-press detection hit-tests against the first direct host element inside Dialog.Content — or, with asChild, inside the element you supplied. Render your panel frame as that first child and keep everything interactive inside it, or presses on your own surface will be treated as outside and dismiss the dialog.
The layered surface is a generated ScreenGui (with ZIndexBehavior.Sibling, ignoring GUI inset) rendered into BasePlayerGui, not the local component tree. Use container on Dialog.Portal to target a specific PlayerGui and displayOrderBase to place the layer’s DisplayOrder band; within a band, later-opened layers stack above earlier ones automatically.
API reference
Dialog.Root
| Prop | Type | Description |
|---|---|---|
| open | boolean | Controlled open state. Pair with onOpenChange. |
| defaultOpen | boolean | Initial open state for uncontrolled usage. Defaults to false. |
| onOpenChange | (open: boolean) => void | Called whenever the open state changes, including outside-press and overlay dismissal. |
| modal | boolean | When true, mounts a full-screen input sink that blocks interaction behind the dialog. Outside presses dismiss in both modes; veto them with the outside-interaction callbacks. Defaults to true. |
| children | React.ReactNode | The dialog parts. |
Dialog.Trigger
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge trigger behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. |
| disabled | boolean | Prevents the trigger from opening the dialog and removes it from focus registration. |
| children | React.ReactElement | The element to render. Required when asChild is set. |
Dialog.Portal
| Prop | Type | Description |
|---|---|---|
| container | BasePlayerGui | Target PlayerGui to render the surface into. Defaults to the app-level portal provider's container. |
| displayOrderBase | number | Base DisplayOrder band for the generated ScreenGui; the layer renders at this base plus a global mount counter. Defaults to 1000. |
| children | React.ReactNode | Overlay and content parts. |
Dialog.Overlay
Renders a TextButton. Unknown props forward onto it and are type-checked against it, so a prop TextButton does not accept is a compile error. The primitive owns Visible, Active, Selectable and Size from presence and full-screen hit-testing, so values you pass for those are ignored.
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge overlay behavior onto the single child element instead of the textbutton the part renders. Use an activatable button class so press-to-close keeps working. |
| forceMount | boolean | Keeps the overlay mounted while closed and through its exit, bypassing the presence wrapper. |
| children | React.ReactNode | Rendered inside the overlay. With asChild, the single element the behavior merges onto instead. |
| …TextButton props | Partial<WritableInstanceProperties<TextButton>> | Forwarded onto the rendered textbutton and type-checked against it. Pass BackgroundColor3 and BackgroundTransparency here for a visible dim. Visible and Active are owned by the primitive. |
Dialog.Content
Renders a Frame. Unknown props forward onto it and are type-checked against it, so a prop Frame does not accept is a compile error. The primitive owns Visible and Size from presence and full-screen layer geometry, so values you pass for those are ignored.
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Make the single child element the motion host instead of the frame the part renders. Pass a canvasgroup here to fade the whole surface as one layer. The outside-press boundary becomes that element's first host child. |
| trapFocus | boolean | Traps focus and selection inside the content while open. Defaults to true. |
| restoreFocus | boolean | Restores focus to the previously focused element on close — the trigger, when one opened the dialog. Defaults to true. |
| forceMount | boolean | Bypasses the presence wrapper: the content stays mounted while closed, long exits run to completion, and child state persists between opens. |
| transition | PresenceMotionConfig | Reveal/exit motion. Used as-is — there is no default underneath, so a partial config animates only the steps it defines. Omit it for no animation. |
| onPointerDownOutside | (event: LayerInteractEvent) => void | Called when a pointer press lands outside the content, before dismissal. Call event.preventDefault() to keep the dialog open. |
| onInteractOutside | (event: LayerInteractEvent) => void | Called after onPointerDownOutside for the same outside interaction, before dismissal. Call event.preventDefault() to keep the dialog open. |
| children | React.ReactNode | The surface contents. The first direct host element is the outside-press hit-test boundary. |
| …Frame props | Partial<WritableInstanceProperties<Frame>> | Forwarded onto the rendered frame — or onto your asChild element — and type-checked against Frame. The host spans the layer, so styling it paints the full screen; style your panel child instead. A transition owns Position while it runs. |
Dialog.Close
| Prop | Type | Description |
|---|---|---|
| asChild | boolean | Merge close behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. |
| children | React.ReactElement | The element to render. Required when asChild is set. |