Every interactive part renders a host element of a fixed class, usually a textbutton, with its
behavior wired onto it. asChild flips that around. The part takes the element you provide and
merges its behavior onto it: handlers, refs, selection flags. Your element becomes the host.
It is powered by Slot from @lattice-ui/react-runtime, which clones your child and composes props onto it.
Every part forwards unknown props onto the instance it renders, so <Dialog.Overlay BackgroundColor3={...} /> works directly. Reach for asChild when you need a different element class, or when the element comes from somewhere else — not merely to change how it looks.
The problem asChild solves
Each part renders one specific instance class. Popover.Trigger renders a textbutton:
<Popover.Trigger Size={UDim2.fromOffset(48, 48)}> {/* a textbutton — styled by the props you pass, but still a textbutton */}</Popover.Trigger>That class is the primitive’s choice. If your design system has its own Button, or you want an
imagebutton, forwarded props will not get you there. They are type-checked against the instance
the part renders, so Image on a textbutton is a compile error.
With asChild, you hand the part your element and it merges behavior onto it:
<Popover.Trigger asChild> <imagebutton Image="rbxassetid://0" Size={UDim2.fromOffset(48, 48)} /></Popover.Trigger>Now the imagebutton is the trigger — the primitive composes its Activated handler, ref and
selection flags onto your element rather than rendering a second node.
How Slot merges props
Slot takes exactly one child and clones it, combining the primitive’s props with the child’s.
Three merge rules:
- Refs are composed. Your child’s ref and the primitive’s both fire, so the primitive can measure or focus the node while your own ref still receives it.
EventandChangehandler tables are chained. Both handlers for the same signal run, the child’s first.- Other props are shallow-merged, with the primitive’s taking precedence for the keys it sets.
<Menu.Item asChild> <textbutton Text="Copy link" Event={{ // Your handler runs, then the primitive's selection handler runs. Activated: () => print("copied"), }} /></Menu.Item>Because Slot composes rather than replaces, you can attach analytics or sound to the same event
the primitive uses.
Slot clones a single child, so the subtree must resolve to exactly one GuiObject. A wrong shape errors rather than silently dropping behavior. Two things do not count against that budget:
- Fragments are looked through. A fragment wrapping a single element resolves to that element.
- Roblox UI modifiers may sit as siblings.
uicorner,uipadding,uilistlayout,uistroke,uishadow,uigradient,uiscale, the constraints and the rest of the creatableUIComponentclasses never compete to be the target. They are re-parented under the element the props land on.
The second rule exists because Roblox attaches modifiers as children rather than properties. That
is the shape a Tailwind-style className transform such as vela-rbxts emits when it
lowers rounded-md or p-2 at the call site:
<Dialog.Trigger asChild> <textbutton Text="Open" Size={UDim2.fromOffset(120, 36)} /> <uicorner CornerRadius={new UDim(0, 8)} /></Dialog.Trigger>Two real GuiObject candidates are still an error — the primitive cannot know which to enhance.
This is what makes a class name on the part work. Vela emits its helper instances as the component’s children, so they arrive here as siblings rather than rival slot candidates. Styling with Vela walks through all three placements.
Where you’ll use it
asChild shows up on the parts that wrap a single interactive or visual host:
- Triggers —
Dialog.Trigger,Popover.Trigger,Select.Trigger, and friends, so your own button opens the surface. - Close buttons —
Dialog.Close, so a styled button inside the content closes it. - Items —
Menu.Itemand similar, so each row is your element. - Overlays and content hosts — so the backdrop or panel frame is yours, with the primitive’s dismissal/positioning behavior merged on.
A trigger that forwards its ref becomes the focus-restore target and the positioning anchor for free, since the primitive composes its ref onto your element.
import { Dialog } from "@lattice-ui/react-dialog";import { Button } from "../ui/Button"; // your design-system button
export function SettingsButton() { return ( <Dialog.Root> <Dialog.Trigger asChild> <Button text="Settings" variant="ghost" /> </Dialog.Trigger> {/* ...portal, overlay, content... */} </Dialog.Root> );}Slot composes the primitive’s ref onto your child only if that child accepts and forwards a ref to a real GuiObject. A wrapper that drops ref breaks focus restoration, positioning and outside-press detection.
When to use it
- You need a different instance class than the part renders — an
imagebuttonwhere it renders atextbutton, acanvasgroupwhere it renders aframe. - Your app already has the right visual element, such as a design-system button or a styled frame, and you only want the behavior.
- You need to size a
Contentpart, whose geometry the primitive otherwise owns. - You want to attach your own handlers to the same event the primitive uses, without losing either.
When not to use it
- You only want to restyle the part. Pass the props directly. They forward onto the rendered instance and are type-checked against it.
- The part needs to render multiple internal nodes to do its job.
Slotclones one child only. Let the part render its own structure. - You would have to spread the merged behavior across several siblings. That is a sign the part’s own host is the right contract.
- The host element is part of the package contract (some parts deliberately render a specific structure). Overriding it can break the primitive’s assumptions.