@lattice-ui/react-dialogStable directionimport Dialogdepends on runtime, focus, layer, motionDialog renders a surface that takes over the screen: confirmations, forms, settings panels. It manages open state, focus, dismissal and exit motion. You render the frame and its contents.
Preview
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
Root, Portal and Content are the minimum useful surface. Trigger, Overlay and Close are
optional, depending on how you drive the dialog.
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, a dimmed overlay and a close button.
Dialog.Overlay covers the screen and dismisses on press whether or not you style it. It draws
nothing until you colour it.
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. Without a
Dialog.Trigger, nothing is focused at open time, so set restoreFocus={false}. An outside press
still closes through onOpenChange. Treat open becoming false as the dismissal 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 for a different backdrop element class, and transition on
Dialog.Content for the entrance. The overlay runs no motion and owns no colour, so the dim and any
fade are yours. It closes the dialog on press, so keep the child an activatable button class.
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 world and other UI stay interactive.
Modality controls blocking, not dismissal. A persistent panel must also veto the close with
event.preventDefault() in onInteractOutside. Drop trapFocus to let gamepad selection leave the
panel.
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 dismisses on press anyway.
Stacked dialogs with display order
Each Dialog.Content mounts its own layered ScreenGui. Its DisplayOrder is displayOrderBase
plus a global mount counter, so a later-opened dialog lands above an earlier one at the same base.
Explicit bands make the ordering deterministic against your other layered UI. Outside presses always
go to the top-most open layer.
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 is not
tied to the settings content tree.
Exit animation with forceMount
Without forceMount, Dialog.Content sits inside a presence wrapper. The wrapper unmounts it once
the exit reports completion, and its short fallback window can cut a long exit off. forceMount
bypasses the wrapper. The content stays mounted while closed, the full exit always plays, and child
state such as scroll position 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
Pass open and onOpenChange to control it, or defaultOpen to run uncontrolled. Every state
change goes through the same setOpen, so onOpenChange sees all of them.
Focus and selection
Dialog.Content wraps its children in a focus scope that traps focus and restores it to whatever was focused just before opening, both on by default.
Dialog.Trigger focuses itself in the same activation that opens the dialog, so the restore
snapshot points at it. Without a trigger, 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 routes to
the top-most open layer only. onPointerDownOutside fires, then onInteractOutside, then the
dialog closes unless either called event.preventDefault().
“Outside” is measured against the first direct host element you render inside Dialog.Content.
Additional top-level host children also count as inside. Under asChild, the rule applies one level
down.
modal (default true) controls blocking, not dismissal: it puts a full-screen input sink
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 and runs no motion of its
own. Pass a transition and it is used as-is. 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 host is a plain Frame spanning the layer. A Frame has no property that fades its
descendants, so animating its BackgroundTransparency fills the screen with a rectangle. 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 andTextTransparency/ImageTransparencyon its children from the sameopenstate. Keep those durations at or under the content transition’s exit duration, or presence unmounts the tree first.
— or hand the dialog a canvasgroup with asChild. Your element becomes the motion host, so
createCanvasGroupRevealRecipe() fades the whole subtree as one composited layer:
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 why it is opt-in.
Presence timing is independent. With or without a transition, the content stays mounted until its
exit resolves. forceMount keeps it mounted while closed and lets long exits run outside the
wrapper’s bounded unmount window.
Dialog.Overlay exposes no transition prop, since 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 it yourself with createOverlayFadeRecipe().
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.
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. |