Latticegetting started

Your first dialog

Build a controlled, modal dialog step by step with the Dialog primitive.

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.

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.

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

The trigger
<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:

Overlay and content
<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:

The transition
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.

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.

Close from inside
<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.

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

Recap

  • Dialog.Root owns the open state; every part reads it through context.
  • Primitives ship unstyled: pass appearance props straight to a part, or use asChild when you need a different instance class.
  • asChild lets your own textbutton become the trigger and close button.
  • Dialog.Content traps and restores focus for free; motion is opt-in through transition.
  • 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.