# Your first dialog

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

Source: https://docs.astra-void.xyz/lattice-ui/getting-started/first-dialog/

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](https://docs.astra-void.xyz/lattice-ui/components/dialog.md).

> **Before you start**
>
> This assumes `@lattice-ui/react-dialog` is installed. If it is not, follow [Installation](https://docs.astra-void.xyz/lattice-ui/getting-started/installation.md) 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.

```tsx title="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.

```tsx title="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:

```tsx title="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:

```tsx title="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](https://docs.astra-void.xyz/lattice-ui/components/dialog.md#motion-and-presence) 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.

> **Presence timing is separate from motion**
>
> 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.

```tsx title="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`.

```tsx title="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.

> **When to control state**
>
> 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.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](https://docs.astra-void.xyz/lattice-ui/getting-started/composition-model.md), or read the full [Dialog reference](https://docs.astra-void.xyz/lattice-ui/components/dialog.md) for every prop.
