npx facet-rbxts add dialogCopies 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>Dialog.Portal reads the PlayerGui it renders into from a strict context. Without a PortalProvider, the dialog compiles, ships, and throws the first time a player opens it. facet add dialog offers to write the wrapper into your client entry.
import { PortalProvider } from "@lattice-ui/react-layer";import React from "@rbxts/react";import { Button } from "../ui/button";import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from "../ui/dialog";import { MODE } from "../facet-mode";
/** * The one scene that wraps itself in a provider, because the component demands * it: `Dialog.Portal` reads the `BasePlayerGui` it renders into from a strict * context, and without one the dialog throws the moment it opens rather than * rendering nowhere. * * In a real project this wrapper is written once, at the client entry — which * is the edit `facet add dialog` offers to make for you. */function getPortalContainer() { const localPlayer = game.GetService("Players").LocalPlayer; if (!localPlayer) { error("[DialogScene] the preview needs a LocalPlayer to portal into."); }
return localPlayer.WaitForChild("PlayerGui") as BasePlayerGui;}
export function ConfirmDialog() { return ( <Dialog defaultOpen> <DialogTrigger asChild> <Button Text="Leave the run" /> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle Text="Leave the run?" /> <DialogDescription Text="Your loot from this run is not banked yet. Leaving now drops it." /> </DialogHeader> <DialogFooter> <DialogClose asChild> <Button size="sm" variant="outline" Text="Stay" /> </DialogClose> <DialogClose asChild> <Button size="sm" variant="destructive" Text="Leave" /> </DialogClose> </DialogFooter> </DialogContent> </Dialog> );}import { fv } from "@facet-ui/react-variants";import { Dialog as DialogPrimitive } from "@lattice-ui/react-dialog";import type { LayerInteractEvent } from "@lattice-ui/react-layer";import { getPassthroughProps, type PassthroughProps, React, toSlotProps } from "@lattice-ui/react-runtime";import { type ClassName, cn } from "~/lib/utils";
/** * The first layered component, and the layering is entirely Lattice's: the * portal, the dim's own `ScreenGui`, the focus trap and the outside-press * dismissal all come from `@lattice-ui/react-dialog`. This file says what the * panel looks like and where it sits. * * **A `PortalProvider` has to be above this.** `Dialog.Portal` reads a strict * context for the `BasePlayerGui` it renders into, so an app that mounts a * dialog wraps its tree once: * * ```tsx * <PortalProvider container={Players.LocalPlayer.WaitForChild("PlayerGui")}> * <App /> * </PortalProvider> * ``` * * Without it the dialog throws on open rather than rendering nowhere, which is * the better of the two failures but still surprising the first time. * * One recipe object rather than seven exports: every exported name costs a * Luau register once Vela inlines its runtime. See * docs/decisions/luau-register-limit.md. */export const dialogVariants = { // The one class in the registry that names a colour instead of a role. A // scrim is not a themed surface — it is the absence of light, black under // shadcn's light theme and its dark one alike, and every role that stays dark // in both modes stays dark by coincidence. `overlayClassName` is the way out. // See docs/decisions/overlay-scrim.md. overlay: fv("bg-black/50"), // `mx-auto my-auto` is the centring: 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. content: fv("flex-col gap-4 w-128 h-fit mx-auto my-auto rounded-lg border border-border bg-background p-6 shadow-lg"), close: fv("size-6 self-end rounded-md text-sm font-normal text-muted-foreground hover:bg-accent"), header: fv("flex-col w-full h-fit gap-2"), footer: fv("flex-row items-center justify-end w-full h-fit gap-2"), title: fv("w-full h-fit whitespace-normal leading-none text-left text-lg font-semibold text-foreground"), description: fv("w-full h-fit whitespace-normal leading-tight text-left text-sm font-normal text-muted-foreground"),};
/** * Re-exported unstyled, as shadcn does: the trigger is whatever the consumer * puts in it. * * ```tsx * <DialogTrigger asChild> * <Button Text="Open" /> * </DialogTrigger> * ``` * * Reached for bare it is a `textbutton` with Roblox's defaults neutralized and * no size of its own, so give it a `className` with both axes resolved. */export const Dialog = DialogPrimitive.Root;export const DialogTrigger = DialogPrimitive.Trigger;export const DialogPortal = DialogPrimitive.Portal;export const DialogClose = DialogPrimitive.Close;
export type DialogOverlayProps = { className?: ClassName;} & PassthroughProps<TextButton>;
export type DialogContentProps = { className?: ClassName; /** Styles the dim behind the panel. `DialogContent` renders its own overlay. */ overlayClassName?: ClassName; /** The ✕ in the panel's top-right. Pass `false` when the only way out is a footer button. */ showCloseButton?: boolean; /** Fires before an outside press dismisses; `event.preventDefault()` keeps the dialog open. */ onPointerDownOutside?: (event: LayerInteractEvent) => void; onInteractOutside?: (event: LayerInteractEvent) => void; children?: React.ReactNode;} & PassthroughProps<Frame>;
export type DialogSectionProps = { className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
export type DialogTextProps = { className?: ClassName; Text?: string;} & PassthroughProps<TextLabel>;
const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0,};
const OVERLAY_OWN_PROPS = ["className", "children"] as const;const CONTENT_OWN_PROPS = [ "className", "overlayClassName", "showCloseButton", "onPointerDownOutside", "onInteractOutside", "children",] as const;const SECTION_OWN_PROPS = ["className", "children"] as const;const TEXT_OWN_PROPS = ["className", "Text"] as const;
// The forwarded bag is widened by `toSlotProps` and then has `children`// dropped from its *type*: the overlay types `children` as the single element// `asChild` merges onto, and the bag never carries one — `children` is listed// as an own prop — so only the type needs narrowing.function forwardProps(props: object, ownKeys: readonly string[]): { key?: React.Key } & { [index: string]: unknown } { return toSlotProps(getPassthroughProps(props, ownKeys));}
export function DialogOverlay(props: DialogOverlayProps) { return ( <DialogPrimitive.Overlay className={cn(dialogVariants.overlay({ className: props.className }))} {...forwardProps(props, OVERLAY_OWN_PROPS)} /> );}
/** * Portal, overlay and panel in one part, like shadcn's — the composition is the * same every time, and `DialogOverlay` is exported for the time it is not. * * The panel is a frame *inside* `Dialog.Content` rather than `Dialog.Content` * itself, and that is structural rather than stylistic. The primitive 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` here would fight the first and — through the `UICorner` Vela * prepends for `rounded-lg` — quietly become the second. */export function DialogContent(props: DialogContentProps) { return ( <DialogPrimitive.Portal> {/* The primitive rather than `DialogOverlay`, so `overlayClassName` and the recipe meet in one `className` expression. Vela resolves a `className` at the call site and hands the component the resolved properties instead of the string, so routing it through a second component would let this file's `bg-black/80` land on top of the consumer's — the same trap `TextSlot` avoids by taking no `className` at all. `overlayClassName` is not spelled `className`, so it arrives here intact. */} <DialogPrimitive.Overlay className={cn(dialogVariants.overlay({ className: props.overlayClassName }))} /> <DialogPrimitive.Content onInteractOutside={props.onInteractOutside} onPointerDownOutside={props.onPointerDownOutside} > <frame className={cn(dialogVariants.content({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, CONTENT_OWN_PROPS)} > {/* Not the corner overlay shadcn draws: a `UIListLayout` positions every child it has, so a floating ✕ inside a `flex-col` panel is not expressible without a second frame to escape the layout. It takes its own line at the top instead, pushed right by `self-end` (a `UIFlexItem`), and the glyph is text — replace it to use your own artwork. */} {props.showCloseButton === false ? undefined : ( <DialogPrimitive.Close className={cn(dialogVariants.close())} Text="✕" /> )} {props.children} </frame> </DialogPrimitive.Content> </DialogPrimitive.Portal> );}
export function DialogHeader(props: DialogSectionProps) { return ( <frame className={cn(dialogVariants.header({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, SECTION_OWN_PROPS)} > {props.children} </frame> );}
export function DialogFooter(props: DialogSectionProps) { return ( <frame className={cn(dialogVariants.footer({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, SECTION_OWN_PROPS)} > {props.children} </frame> );}
export function DialogTitle(props: DialogTextProps) { return ( <textlabel className={cn(dialogVariants.title({ className: props.className }))} Text={props.Text ?? ""} {...NEUTRAL_PROPS} {...getPassthroughProps<TextLabel>(props, TEXT_OWN_PROPS)} /> );}
export function DialogDescription(props: DialogTextProps) { return ( <textlabel className={cn(dialogVariants.description({ className: props.className }))} Text={props.Text ?? ""} {...NEUTRAL_PROPS} {...getPassthroughProps<TextLabel>(props, TEXT_OWN_PROPS)} /> );}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 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 init writes vela.config.ts only when there is none, and merely reports on tsconfig.json. Both belong to you. This is the exception because everything else the CLI reports fails at build time, in front of the person who just ran the command. A missing PortalProvider fails at runtime, in production.
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:
<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-autoVela 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.
| candidate | dark | light |
|---|---|---|
bg-background/80 | zinc-950 ✓ | white ✗ |
bg-foreground/80 | zinc-50 ✗ | zinc-950 ✓ |
bg-muted/80 | zinc-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.
Loom’s Enum table has no ItemLineAlignment, so it neither reads nor lays out what self-end lowers to, and the glyph sits where the list put it. The docs’ gallery backfills the enum so the scene renders.
Three questions Studio has not answered yet. Whether the ✕ reaches the panel’s right edge, whether the panel lands centred, and whether the dim covers the screen.