Every Lattice UI primitive is built from the same three ideas. A compound component with a Root
and named parts, an asChild escape hatch for merging behaviour onto your own elements, and
controllable state. Learn them once with Dialog and they carry over unchanged.
The component pages assume you know it.
Compound components
A primitive is a namespace object: Root owns the state and shares it through React context, and
the parts read from that context rather than holding copies.
import { Dialog } from "@lattice-ui/react-dialog";
<Dialog.Root> <Dialog.Trigger /> <Dialog.Portal> <Dialog.Overlay /> <Dialog.Content /> </Dialog.Portal></Dialog.Root>;The same shape repeats everywhere — only the part names change to fit the primitive:
import { Tabs } from "@lattice-ui/react-tabs";
<Tabs.Root defaultValue="general"> <Tabs.List> <Tabs.Trigger value="general" /> <Tabs.Trigger value="audio" /> </Tabs.List> <Tabs.Content value="general" /> <Tabs.Content value="audio" /></Tabs.Root>;The parts share one context. A Tabs.Trigger knows which panel is active and a Dialog.Close knows
how to close its Root, with no props threaded between them.
Controllable state
State-bearing roots are built on useControllableState from @lattice-ui/react-runtime: pass a
controlled value, or pass a default and let the primitive own it.
Overlay primitives expose open state:
open— the controlled valuedefaultOpen— the initial value when uncontrolledonOpenChange— called on every change, in both modes
// Uncontrolled — Dialog owns the state.<Dialog.Root defaultOpen={false}>{/* ... */}</Dialog.Root>
// Controlled — your screen owns the state.const [open, setOpen] = useState(false);<Dialog.Root open={open} onOpenChange={setOpen}>{/* ... */}</Dialog.Root>Value-bearing primitives follow the identical pattern with value instead of open — for example Tabs.Root accepts value, defaultValue, and onValueChange:
<Tabs.Root defaultValue="general">{/* ... */}</Tabs.Root>
const [tab, setTab] = useState("general");<Tabs.Root value={tab} onValueChange={setTab}>{/* ... */}</Tabs.Root>Pass the controlled prop and the primitive defers to you, reporting changes through on*Change.
Omit it and the primitive holds the state internally, and still reports changes.
Pass either the controlled prop or the default, not both. onOpenChange / onValueChange fire either way, so you never lose the change event by going uncontrolled.
asChild
Behaviour-carrying parts render their own host element. asChild tells the part to merge its
behaviour onto the single child you provide instead, through Slot. Reach for it when you need a
different instance class than the part renders, or when your app already owns the right element.
Styling a part is not a reason to use it — unknown props forward onto the instance the part renders.
<Dialog.Trigger asChild> <textbutton Text="Open settings" Size={UDim2.fromOffset(140, 38)} /></Dialog.Trigger>Slot does a real merge, not a replacement. It composes refs across the part and your element, and chains event handlers (Event and Change) so both the part’s handler and yours run. That is why the trigger above still opens the dialog while keeping any Event.Activated you set on the textbutton.
asChild needs the subtree to resolve to exactly one GuiObject. Fragments are looked through, and
Roblox UI modifiers such as uicorner and uipadding may sit alongside it. The parts that support
it say so in their reference tables: triggers, close buttons, overlays and similar. Do not assume
every part takes it.
App-owned visuals
The split is deliberate: the primitive owns interaction, your app owns presentation. Frames, sizing, color, typography, and layout live in your code. This is what makes the same primitive fit a settings panel, a store window, and a confirmation prompt without forking it.
That split is literal. A primitive sets behavior plus the minimum needed to neutralize Roblox’s own
instance defaults, and nothing else. A bare textbutton would otherwise render an opaque grey box
labelled “Button”. No colors, no fixed sizes, no font sizes, no placeholder text. What a primitive
does keep is geometry it computes from state: progress fill ratios, slider thumb travel,
popper-resolved position, scroll thumb size.
<Dialog.Content> <frame BackgroundColor3={Color3.fromRGB(24, 26, 32)} Size={UDim2.fromOffset(320, 180)} > {/* your layout, your tokens */} </frame></Dialog.Content>Dialog.Content still traps focus and handles dismissal — neither of which the frame inside it has to know about. It holds its content mounted until any exit transition finishes too, though the transition itself is opt-in: pass transition when you want one.
Next step
Apply the model end to end in Your first dialog, then browse the Dialog reference to see the per-part props in context.