Facetcomponents

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.

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

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.

candidatedarklight
bg-background/80zinc-950 ✓white ✗
bg-foreground/80zinc-50 ✗zinc-950 ✓
bg-muted/80zinc-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.