Latticecomponents

Dialog

A modal surface owning open state, focus trapping, layered dismissal and presence motion.

@lattice-ui/react-dialogStable directionimport Dialogdepends on runtime, focus, layer, motion

Dialog 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

Edit

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 anatomy
<Dialog.Root>
<Dialog.Trigger />
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content />
</Dialog.Portal>
</Dialog.Root>
PartRequiredResponsibility
Dialog.RootyesOwns open state and shares it with every part through context.
Dialog.TriggernoA button that opens the dialog and is the default focus-restore target.
Dialog.PortalyesRenders the surface into a ScreenGui outside the local tree.
Dialog.OverlaynoA full-screen backdrop behind the content that closes the dialog on press.
Dialog.ContentyesThe focus-trapped, dismissable surface.
Dialog.ClosenoA 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.

QuestDialog.tsx
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.

TradeRequestDialog.tsx
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.

StoreDialog.tsx
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.

CraftingPanel.tsx
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.

ResetSettingsDialog.tsx
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.

MatchResultsDialog.tsx
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:

Opting into the reveal
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 inside Dialog.Overlay covers most of what a dialog reveal reads as.
  • Fade your own elements. Animate BackgroundTransparency on your panel and TextTransparency / ImageTransparency on its children from the same open state. 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:

Opting into a whole-surface fade
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().

API reference

Dialog.Root

PropTypeDescription
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanInitial open state for uncontrolled usage. Defaults to false.
onOpenChange(open: boolean) => voidCalled whenever the open state changes, including outside-press and overlay dismissal.
modalbooleanWhen 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.
childrenReact.ReactNodeThe dialog parts.

Dialog.Trigger

PropTypeDescription
asChildbooleanMerge trigger behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button.
disabledbooleanPrevents the trigger from opening the dialog and removes it from focus registration.
childrenReact.ReactElementThe element to render. Required when asChild is set.

Dialog.Portal

PropTypeDescription
containerBasePlayerGuiTarget PlayerGui to render the surface into. Defaults to the app-level portal provider's container.
displayOrderBasenumberBase DisplayOrder band for the generated ScreenGui; the layer renders at this base plus a global mount counter. Defaults to 1000.
childrenReact.ReactNodeOverlay 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.

PropTypeDescription
asChildbooleanMerge 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.
forceMountbooleanKeeps the overlay mounted while closed and through its exit, bypassing the presence wrapper.
childrenReact.ReactNodeRendered inside the overlay. With asChild, the single element the behavior merges onto instead.
…TextButton propsPartial<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.

PropTypeDescription
asChildbooleanMake 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.
trapFocusbooleanTraps focus and selection inside the content while open. Defaults to true.
restoreFocusbooleanRestores focus to the previously focused element on close — the trigger, when one opened the dialog. Defaults to true.
forceMountbooleanBypasses the presence wrapper: the content stays mounted while closed, long exits run to completion, and child state persists between opens.
transitionPresenceMotionConfigReveal/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) => voidCalled when a pointer press lands outside the content, before dismissal. Call event.preventDefault() to keep the dialog open.
onInteractOutside(event: LayerInteractEvent) => voidCalled after onPointerDownOutside for the same outside interaction, before dismissal. Call event.preventDefault() to keep the dialog open.
childrenReact.ReactNodeThe surface contents. The first direct host element is the outside-press hit-test boundary.
…Frame propsPartial<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

PropTypeDescription
asChildbooleanMerge close behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button.
childrenReact.ReactElementThe element to render. Required when asChild is set.