# Dialog

> Modal surface primitive that owns open state, focus trapping, layered dismissal, and presence motion while you own the visuals.

Source: https://docs.astra-void.xyz/lattice-ui/components/dialog/

`@lattice-ui/react-dialog` · Stable direction · import `Dialog` · depends on `runtime`, `focus`, `layer`, `motion`

Dialog is the primitive for any surface that should take over the screen: confirmations, forms, settings panels, and store windows. It coordinates open state, focus, dismissal, and exit motion so your component only has to render the frame and its contents.

Reach for Dialog when a surface needs to be **modal** (block interaction behind it), **restore focus** to whatever opened it, and **dismiss predictably** — by an explicit close, an outside interaction, or a controlled state change.

## Preview

The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive.

{/* The dialog panel is a fixed 420px wide — the only scene on these pages that
    a phone's docs column would clip. `base` keeps it composed and scaled to fit. */}

_Interactive preview._

## Import

```ts
import { Dialog } from "@lattice-ui/react-dialog";
```

## Anatomy

Compose the full set of parts. `Trigger`, `Overlay`, and `Close` are optional depending on how you drive the dialog, but `Root`, `Portal`, and `Content` form the minimum useful surface.

```tsx title="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 that opens it, a dimmed overlay, and a close button inside the content.

`Dialog.Overlay` covers the screen and dismisses the dialog on press whether or not you style it — but it draws nothing until you give it a color, so pass `BackgroundColor3` and `BackgroundTransparency` for a visible dim.

```tsx title="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 — a server event, a touched part, a timer. There is no `Dialog.Trigger` here, which changes focus restoration: a trigger focuses itself the moment it opens the dialog, so the focus scope has a reliable snapshot to restore. Opened from a game event, there is usually nothing meaningful focused at open time, so opt out with `restoreFocus={false}` (or focus a specific element yourself when the dialog closes).

Note that an outside press still closes the dialog through `onOpenChange`, so treat `open` becoming `false` — not just the buttons — as the "dismissed" signal.

```tsx title="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` when you need a different element class for the backdrop, and pass `transition` on `Dialog.Content` for the entrance. Two things to design around: the overlay runs no motion of its own and owns no color, so both the dim and any fade are yours; and it closes the dialog on press, so keep the child an activatable button class. The content's `transition` is used as-is (see [Motion and presence](#motion-and-presence)) — here a taller 24px rise.

```tsx title="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 game world and other UI behind the panel stay interactive. Outside presses still route to the dialog and would dismiss it — modality controls blocking, not dismissal — so a persistent panel also has to veto the close by calling `event.preventDefault()` in `onInteractOutside`. Dropping `trapFocus` lets gamepad selection leave the panel too. The result closes only through its own button.

```tsx title="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 the default overlay would dismiss on press anyway.

### Stacked dialogs with display order

Each `Dialog.Content` mounts its own layered `ScreenGui` whose `DisplayOrder` is `displayOrderBase` plus a global mount counter, so a later-opened dialog already lands above an earlier one at the same base. Explicit `displayOrderBase` bands make the ordering deterministic against your other layered UI — here the settings dialog sits in the 1000 band and its destructive confirmation in the 2000 band. Outside presses always go to the top-most open layer, so pressing the settings surface while the confirmation is up dismisses only the confirmation.

```tsx title="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 survives its own open/close cycle without being tied to the settings content tree.

### Exit animation with forceMount

Without `forceMount`, `Dialog.Content` sits inside a presence wrapper that unmounts it after the exit motion reports completion, with a short fallback window as a safety net — long, expressive exits risk being cut off by that fallback. `forceMount` bypasses the presence wrapper entirely: the content stays mounted while closed (hidden once the exit finishes), the full exit intent always plays, and child state such as scroll position or `TextBox` contents survives between opens.

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

`Dialog.Root` is controllable. Pass `open` and `onOpenChange` to control it, or `defaultOpen` to run uncontrolled (defaulting to closed). Every path to a state change — trigger activation, close button, overlay press, outside-press dismissal — goes through the same `setOpen`, so `onOpenChange` sees all of them and controlled and uncontrolled usage behave identically.

### Focus and selection

`Dialog.Content` wraps its children in a focus scope. By default it **traps focus** (`trapFocus` defaults to `true`) so gamepad and selection movement stay inside the surface while it is open, and **restores focus** (`restoreFocus` defaults to `true`) to whatever was focused just before it opened.

`Dialog.Trigger` is what makes restoration reliable: it registers itself as a focus node and focuses itself in the same activation that opens the dialog, so the scope's restore snapshot points at the trigger. When you open a dialog without a trigger — controlled state driven by a game event — there may be nothing focused at open time; either 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 (mouse button or touch, ignoring input the engine already processed) is routed to the **top-most open layer only**: `onPointerDownOutside` fires first, then `onInteractOutside` for the same press, and then the dialog closes unless either handler called `event.preventDefault()`. This is how stacked dialogs behave sanely — a press on a lower dialog dismisses only the top one.

"Outside" is measured against the first direct host element you render inside `Dialog.Content` (your panel frame); additional top-level host children also count as inside. Presses within those bounds never trigger dismissal. Under `asChild` the same rule applies one level down, against the first host child of the element you supplied.

`modal` (default `true`) controls blocking, not dismissal: when modal, a full-screen input sink behind the content swallows interaction with everything 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`, but runs **no motion of its own**. Pass a `transition` to animate it; the config you pass is used as-is, with nothing underneath to merge with. What the transition can move is the host — animate `Position` and the whole surface slides:

```tsx title="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 default host is a plain `Frame` spanning the whole layer, and a `Frame` has no property that fades its descendants — `BackgroundTransparency` on the host fades the host's own background, which covers the screen. Animating it through `transition` fills the screen with a rectangle instead of fading your panel. So either 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 frame and `TextTransparency` / `ImageTransparency` on the children that need it, driven by the same `open` state you pass to `Dialog.Root`. Keep those durations at or under the content transition's exit duration, or presence unmounts the tree before your fade finishes.

— or hand the dialog a `canvasgroup` with `asChild`. Your element becomes the motion host, so `createCanvasGroupRevealRecipe()` fades the whole subtree as one composited layer, the way the primitive used to before it stopped rendering a `CanvasGroup` for every dialog:

```tsx title="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 exactly why it is opt-in rather than the default.

> **asChild moves the outside-press boundary down a level**
>
> Outside presses are measured against the first direct host element inside `Dialog.Content`. With `asChild` your element *is* the host and spans the layer, so the boundary becomes its first host child instead — the panel above. Keep your panel as that first child, exactly as you would without `asChild`.

Presence timing is independent of that: with or without a `transition`, the content stays mounted until its exit resolves, so an exit animation is never cut off. `forceMount` on either part keeps it mounted while closed and lets long exits run outside the presence wrapper's bounded unmount window.

`Dialog.Overlay` exposes no `transition` prop — 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 that element yourself; `createOverlayFadeRecipe()` describes the fade the primitive used to run.

> **An unstyled overlay is invisible, not absent**
>
> `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.

> **Make your panel the first child of Content**
>
> Outside-press detection hit-tests against the first direct host element inside `Dialog.Content` — or, with `asChild`, inside the element you supplied. Render your panel frame as that first child and keep everything interactive inside it, or presses on your own surface will be treated as outside and dismiss the dialog.

> **Roblox layering**
>
> 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. |

## Related

- [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md)
- [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md)
- [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
