Facetcomponents

Dialog

The first layered component — a required provider, a panel that is not the primitive's content, and one hard-coded colour.

Terminal window
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.

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>
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

PartRendersClasses
Dialog, DialogTrigger, DialogPortal, DialogClose—Re-exported from Lattice unstyled
DialogOverlayTextButtonbg-black/80
DialogContentFrameflex-col gap-4 w-96 h-fit mx-auto my-auto rounded-lg border border-border bg-background p-6
DialogHeaderFrameflex-col w-full h-fit gap-2
DialogFooterFrameflex-row items-center justify-end w-full h-fit gap-2
DialogTitleTextLabelw-full h-fit whitespace-normal text-left text-lg font-semibold text-foreground
DialogDescriptionTextLabelw-full h-fit whitespace-normal leading-tight text-left text-sm font-normal text-muted-foreground

The four re-exports pass DialogPrimitive.Root, .Trigger, .Portal and .Close through without a recipe. Bare, DialogTrigger is a textbutton with no size of its own — give it a className with both axes resolved, or asChild around a Button.

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

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 declares the same one, so this edit happens once.

facet doctor notices when the provider goes missing again.

Props

DialogContent

PropTypeDescription
overlayClassNameClassNameStyles the dim behind the panel. Spelled this way on purpose — see The scrim below.
showClosebooleanThe ✕ in the panel. Pass false for a dialog whose only way out is a footer button.
onPointerDownOutside(event: LayerInteractEvent) => voidFires before an outside press dismisses. event.preventDefault() keeps the dialog open.
onInteractOutside(event: LayerInteractEvent) => voidThe same, for any outside interaction.
classNameClassNameThreaded 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:

<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 takes the first host element under it as the outside-press boundary. A className on the primitive 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, which works because the primitive’s content host spans the layer and lays nothing out.

The scrim

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

The one class in the registry naming a colour instead of a role, against rule 8. A scrim has to darken whatever is under it in every theme, and none of Facet’s nineteen roles is dark in both modes.

candidatedarklight
bg-background/80zinc-950 ✓white ✗
bg-foreground/80zinc-50 ✗zinc-950 ✓
bg-muted/80zinc-800 ✓zinc-100 ✗

No token was added either. A token is a published surface: facet doctor checks it, @facet-ui/theme ships the default, and every upgrade inherits it. That is a permanent commitment for one class in one component.

overlayClassName, not className

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

The name is the point. Vela intercepts a prop named className at the call site and hands the component resolved properties rather than the string. A class routed through a second component’s className is overwritten by its recipe.

overlayClassName is not className, so it arrives intact and merges where the overlay resolves — the TextSlot trap one level up.

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, pushed right by self-end:

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. A floating child inside a flex-col panel would need a second frame purely to escape the layout, which is the wrapper rule 5 says not to add. self-end is a UIFlexItem, the in-layout way to say the same thing.

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