# Dialog

> The first layered component — a provider your app has to have, a panel that is not the primitive's content, and the one class in the registry that names a colour.

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

```bash
npx facet-rbxts add dialog
```

Copies `ui/dialog.tsx`, plus `lib/utils.ts`. Needs `@facet-ui/react-variants`,
`@lattice-ui/react-runtime@^0.8.0`, `@lattice-ui/react-dialog@^0.8.0` and
`@lattice-ui/react-layer@^0.8.0`.

```tsx
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "../shared/ui/dialog";

<Dialog>
  <DialogTrigger asChild>
    <Button Text="Leave the run" />
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle Text="Leave the run?" />
      <DialogDescription Text="Your loot is not banked yet." />
    </DialogHeader>
    <DialogFooter>
      <DialogClose asChild>
        <Button size="sm" variant="outline" Text="Stay" />
      </DialogClose>
    </DialogFooter>
  </DialogContent>
</Dialog>
```

> **This one needs a provider above your app**
>
> `Dialog.Portal` reads the `PlayerGui` it renders into from a **strict** context. Without a
> `PortalProvider` the dialog compiles, type-checks, ships — and throws the first time a player opens
> it. `facet add dialog` offers to write the wrapper into your client entry; the details are in
> [The provider](#the-provider) below.

_Interactive preview: Open on load. The ✕ should sit at the panel's right edge — Loom does not lay out self-end, so here it does not, which is one of the things only Studio can answer._

## The parts

| Part | Renders | Classes |
| --- | --- | --- |
| `Dialog`, `DialogTrigger`, `DialogPortal`, `DialogClose` | — | Re-exported from Lattice unstyled |
| `DialogOverlay` | `TextButton` | `bg-black/80` |
| `DialogContent` | `Frame` | `flex-col gap-4 w-96 h-fit mx-auto my-auto rounded-lg border border-border bg-background p-6` |
| `DialogHeader` | `Frame` | `flex-col w-full h-fit gap-2` |
| `DialogFooter` | `Frame` | `flex-row items-center justify-end w-full h-fit gap-2` |
| `DialogTitle` | `TextLabel` | `w-full h-fit whitespace-normal text-left text-lg font-semibold text-foreground` |
| `DialogDescription` | `TextLabel` | `w-full h-fit whitespace-normal leading-tight text-left text-sm font-normal text-muted-foreground` |

The four re-exports are `DialogPrimitive.Root`, `.Trigger`, `.Portal` and `.Close`, passed through
without a recipe — the trigger is whatever you put in it, exactly as shadcn does. Reached for bare,
`DialogTrigger` is a `textbutton` with Roblox's defaults neutralized and no size of its own, so give
it a `className` with both axes resolved, or use `asChild` around a
[`Button`](https://docs.astra-void.xyz/facet/components/button.md).

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.

That is `DialogContent`, `DialogHeader` and `DialogFooter`. The two text parts forward onto a
`TextLabel`, and `DialogOverlay` onto a `TextButton`.

## The provider

```tsx
import { PortalProvider } from "@lattice-ui/react-layer";

<PortalProvider container={Players.LocalPlayer.WaitForChild("PlayerGui")}>
  <App />
</PortalProvider>
```

One wrapper, at the client entry, for the whole app — not per dialog. Every layered component in the
registry will declare the same one, so this edit happens once.

> **Why the CLI edits a file for this and nothing else**
>
> `facet init` writes `vela.config.ts` only when there is none, and merely *reports* on `tsconfig.json`
> rather than editing it: both files belong to you, and a pattern-matched edit that mangles one is
> worse than a printed snippet.
>
> This is the exception, because every other thing the CLI reports is a **build-time** failure. A
> missing transformer means every class is inert on the next `rbxtsc`. A missing token is a Vela
> diagnostic. Both are loud, and both land in front of the person who just ran the command.
>
> A missing `PortalProvider` is none of those. It fails at runtime, in production, when a player
> presses the button — and by then the snippet scrolled past hundreds of lines of package-manager
> output. So `facet add` parses your entry, asks, and writes it. See the
> [CLI reference](https://docs.astra-void.xyz/facet/reference/cli.md#wiring-a-provider).

`facet doctor` notices when the provider goes missing again.

## Props

### `DialogContent`

| Prop | Type | Description |
| --- | --- | --- |
| `overlayClassName` | `ClassName` | Styles the dim behind the panel. Spelled this way on purpose — see The scrim below. |
| `showClose` | `boolean` | The ✕ in the panel. Pass false for a dialog whose only way out is a footer button. |
| `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Fires before an outside press dismisses. event.preventDefault() keeps the dialog open. |
| `onInteractOutside` | `(event: LayerInteractEvent) => void` | The same, for any outside interaction. |
| `className` | `ClassName` | Threaded into the panel recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site. |

`Dialog` takes `open`, `defaultOpen`, `onOpenChange` and `modal` from Lattice. `DialogHeader` and
`DialogFooter` take `children` and `className`; the two text parts take `Text` and `className`.

## The panel is a frame inside `Dialog.Content`

This is structural rather than stylistic, and it is the shape the rest of the layered tier will
inherit:

```tsx
<DialogPrimitive.Content …>
  <frame className={cn(dialogVariants.content({ className: props.className }))} …>
```

`Dialog.Content` forces `Size` on its own host so the layer spans the screen, **and** it takes the
first host element under it as the boundary an outside press is measured against. A `className` on
the primitive itself fights the first — and, through the `UICorner` Vela prepends for `rounded-lg`,
quietly becomes the second.

So the styled panel is a child, and its own centring is two classes:

```
mx-auto my-auto
```

Vela lowers each to `AnchorPoint` 0.5 plus `Position` 0.5 on that axis. It works because the
primitive's content host spans the layer and lays nothing out, so this frame positions itself inside
it.

## The scrim

```tsx
overlay: fv("bg-black/80"),
```

The one class in the registry that names a colour instead of a role, against
[rule 8](https://docs.astra-void.xyz/facet/guides/component-conventions.md#8-roles-never-ramp-steps). It is deliberate: a scrim
has to darken whatever is under it *in every theme*, and not one of Facet's nineteen roles is dark in
both modes — that is what makes them roles.

| candidate | dark | light |
| --- | --- | --- |
| `bg-background/80` | zinc-950 ✓ | white ✗ |
| `bg-foreground/80` | zinc-50 ✗ | zinc-950 ✓ |
| `bg-muted/80` | zinc-800 ✓ | zinc-100 ✗ |

A token was not added for it either. A token is a published surface — `facet doctor` checks a
project's theme against the tokens each installed component names, `@facet-ui/theme` ships the
defaults, every upgrade inherits it — which is a large permanent commitment for one class in one
component, encoding something ("a scrim is dark") nobody will want to retheme. If `sheet` and
`drawer` turn out to want it too, that is when the argument restarts.

### `overlayClassName`, not `className`

```tsx
<DialogContent overlayClassName="bg-background/60">
```

The name is the point. Vela intercepts a prop *named* `className` at the call site and hands the
component the resolved properties rather than the string, so a class routed through a second
component's `className` is overwritten by that component's own recipe.

`overlayClassName` is not `className`, so it arrives intact and merges into the single expression
where the overlay actually resolves. That is
[the `TextSlot` trap](https://docs.astra-void.xyz/facet/guides/text-and-labels.md#textslot-takes-no-classname) one level up, and
it is why `DialogContent` renders `DialogPrimitive.Overlay` directly instead of reaching for its own
`DialogOverlay`.

The recipe is also exported as `dialogVariants.overlay`, and the file is yours once copied.

## The corner ✕ is not a corner ✕

shadcn floats the close button over the panel's top-right. This one takes its own line at the top,
pushed right by `self-end`:

```tsx
close: fv("size-6 self-end rounded-md text-sm font-normal text-muted-foreground hover:bg-accent"),
```

A `UIListLayout` positions **every** child it has, so a floating child inside a `flex-col` panel is
not expressible without a second frame purely to escape the layout — which is the wrapper
[rule 5](https://docs.astra-void.xyz/facet/guides/component-conventions.md#5-layout-is-an-instance-not-a-property) says not to
add. `self-end` is a `UIFlexItem`, which is the in-layout way to say the same thing.

`showClose={false}` turns it off for a dialog whose only exit is a footer button.

> **Where the ✕ lands is not something the preview can show**
>
> Loom's `Enum` table has no `ItemLineAlignment`, so it neither reads nor lays out what `self-end`
> lowers to — the glyph sits where the list put it, at the left. The docs' Facet gallery backfills the
> enum so the scene renders at all rather than crashing.
>
> Whether the ✕ actually reaches the panel's right edge, whether the panel lands centred, and whether
> the dim covers the screen beneath it are the three geometry questions
> [Studio has not answered yet](https://docs.astra-void.xyz/facet/getting-started/scope-and-status.md#what-is-not-covered) for this
> component.
