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, 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 below.
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 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.
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 in the registry will declare 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
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.
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 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-autoVela 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
overlay: fv("bg-black/80"),The one class in the registry that names a colour instead of a role, against rule 8. 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
<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 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:
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 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.
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 for this component.