Dialog is a good first primitive because it shows the whole Lattice UI model in one surface: a Root that owns state, parts that read from it, and an app-owned frame for the visuals. By the end of this walkthrough you will have a working confirmation dialog that opens from a button, traps focus, and closes predictably.
This page is a guided build, not a reference. For the full prop list and behavior details, see the Dialog component page.
This assumes @lattice-ui/react-dialog is installed. If it is not, follow Installation first.
Step 1 — Import and sketch the anatomy
Dialog is a compound component: a Root and a set of parts hanging off it. Start from the import and the shape you are going to fill in.
import React from "@rbxts/react";import { Dialog } from "@lattice-ui/react-dialog";
export function ConfirmDialog() { return ( <Dialog.Root> <Dialog.Trigger /> <Dialog.Portal> <Dialog.Overlay /> <Dialog.Content /> </Dialog.Portal> </Dialog.Root> );}Root, Portal, and Content are the minimum useful surface; Trigger, Overlay, and Close are there to drive and dress it.
This sketch compiles, but it renders nothing you can see — every part is unstyled until you say otherwise. That is the anatomy, not a working dialog; the next steps fill it in.
Step 2 — Add a trigger
Dialog.Trigger is the button that opens the surface. Use asChild so your own textbutton becomes the trigger instead of rendering a default one — the trigger behavior merges onto the element you provide.
<Dialog.Trigger asChild> <textbutton Text="Delete save" Size={UDim2.fromOffset(140, 38)} /></Dialog.Trigger>The trigger also registers itself as the focus-restore target, so when the dialog closes, selection returns here automatically.
Step 3 — Build the surface
Dialog.Portal renders the surface into a ScreenGui above your game UI, Dialog.Overlay is the backdrop, and Dialog.Content is the focus-trapped, dismissable panel. The visuals are entirely yours — Lattice only owns the behavior.
That is literal: an overlay you do not style is fully transparent. It still covers the screen and still swallows clicks (which is how outside-press dismissal works), but it draws nothing. Give it a color to get a visible dim. Because props forward onto the instance each part renders, you can style the overlay directly rather than nesting a frame inside it:
<Dialog.Portal> <Dialog.Overlay BackgroundColor3={Color3.fromRGB(0, 0, 0)} BackgroundTransparency={0.5} />
<Dialog.Content transition={DIALOG_RISE}> <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)} > <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(280, 40)} Text="Delete this save?" TextColor3={Color3.fromRGB(240, 244, 250)} /> </frame> </Dialog.Content></Dialog.Portal>Dialog.Content traps focus while open and restores it on close without any configuration. Motion is opt-in: leave transition off and the dialog still opens and closes correctly, just instantly.
DIALOG_RISE is the transition itself. Dialog.Content renders a full-screen Frame, so what it can animate is where the surface sits — an 8-pixel rise on the way in, the same offset on the way out:
import { motionTargets, type PresenceMotionConfig } from "@lattice-ui/react-motion";
const DIALOG_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" }, },};Keep the transition to Position. The host spans the whole layer, so fading its BackgroundTransparency fills the screen with a rectangle rather than fading the panel — see Fading a dialog for how to fade the surface itself. The target declares which properties motion is allowed to own, which is what keeps it from fighting your layout.
Even with no transition, content stays mounted until its exit finishes, so an exit animation can never be cut off mid-way. The transition decides what animates; presence decides when the tree unmounts.
Step 4 — Add a close action
Put a Dialog.Close inside the content for an explicit close. Like the trigger, it accepts asChild so your own button drives it.
<Dialog.Close asChild> <textbutton Text="Cancel" Size={UDim2.fromOffset(100, 34)} /></Dialog.Close>Because the dialog is modal by default, an outside press also dismisses it — so the close button is a convenience, not the only way out.
Step 5 — Control the open state
So far the dialog runs uncontrolled: the trigger and close button drive it through shared context. That is enough for most cases. When something outside the dialog needs to open or close it, lift the state with open and onOpenChange.
import React, { useState } from "@rbxts/react";import { Dialog } from "@lattice-ui/react-dialog";import { motionTargets, type PresenceMotionConfig } from "@lattice-ui/react-motion";
const DIALOG_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" }, },};
export function ConfirmDialog() { const [open, setOpen] = useState(false);
return ( <Dialog.Root open={open} onOpenChange={setOpen}> <Dialog.Trigger asChild> <textbutton Text="Delete save" Size={UDim2.fromOffset(140, 38)} /> </Dialog.Trigger>
<Dialog.Portal> <Dialog.Overlay BackgroundColor3={Color3.fromRGB(0, 0, 0)} BackgroundTransparency={0.5} />
<Dialog.Content transition={DIALOG_RISE}> <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)} > <textlabel BackgroundTransparency={1} Size={UDim2.fromOffset(280, 40)} Text="Delete this save?" TextColor3={Color3.fromRGB(240, 244, 250)} /> <Dialog.Close asChild> <textbutton Text="Cancel" Size={UDim2.fromOffset(100, 34)} /> </Dialog.Close> </frame> </Dialog.Content> </Dialog.Portal> </Dialog.Root> );}Controlled and uncontrolled usage behave identically — the trigger, the close button, and an outside press all flow through the same state, so onOpenChange fires no matter how the dialog opens or closes.
Reach for open/onOpenChange only when an outside actor needs to drive the dialog — a server response, a hotkey, or a parent screen. Otherwise prefer defaultOpen and let Dialog own it.
Recap
Dialog.Rootowns the open state; every part reads it through context.- Primitives ship unstyled: pass appearance props straight to a part, or use
asChildwhen you need a different instance class. asChildlets your owntextbuttonbecome the trigger and close button.Dialog.Contenttraps and restores focus for free; motion is opt-in throughtransition.- A modal dialog dismisses on an outside press, not just the close button.
Next step
See how this same shape repeats across every primitive in the Composition model, or read the full Dialog reference for every prop.