Every interactive part of a Lattice primitive has to render something — a Dialog.Trigger needs a button to click, a Menu.Item needs a row to select. Each part renders a host element of a fixed class (usually a textbutton) with its behavior wired onto it.
asChild flips that around. Instead of rendering its own host, the part takes the element you provide and merges its behavior — event handlers, refs, and selection flags — onto it. Your element becomes the host; the primitive just enhances it.
Under the hood this is powered by the Slot component from @lattice-ui/react-runtime, which clones your child and composes props onto it.
Since 0.7.0 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 than the part renders, 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, not yours. If your design system already has a Button component, or you want an imagebutton, no amount of forwarded props will get you there — forwarded props 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, its ref, and its selection flags onto your element instead of rendering a second node.
How Slot merges props
When a part is in asChild mode it renders through Slot. Slot takes exactly one child and clones it, combining the primitive’s props with the child’s props. The merge rules are important to understand:
- Refs are composed. Your child’s ref and the primitive’s ref both fire, so the primitive can measure or focus the node while your own ref still receives it. You never have to choose between them.
EventandChangehandler tables are chained. If both the primitive and your child define a handler for the same signal (sayActivated), both run — the child’s handler first, then the primitive’s. Neither one clobbers the other.- Other props are shallow-merged, with the primitive’s props taking precedence for the keys it sets (for example
ActiveorSelectableon a trigger).
<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 your own analytics or sound effects to the same event the primitive uses for its behavior.
Slot clones a single child, so the subtree must resolve to exactly one GuiObject element. Parts call error("... `asChild` requires a child element.") when it does not, so a wrong shape fails loudly rather than silently dropping behavior.
Two things do not count against that one-element budget, as of 0.7.0:
- 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 are re-parented under the element the props land on instead of competing to be the target.
On 0.7.x that second rule is documented but inert. The lookup that recognises a modifier was keyed by the JSX tag, and @rbxts/react rewrites a host tag to its Roblox class name before it builds the element — <uicorner /> reaches Slot as "UICorner" — so every modifier missed it, counted as a second candidate, and asChild failed with “expected exactly one child element besides any UI modifiers” on precisely the subtrees the rule was written for. Fixed in 0.8.0.
Which spelling arrives depends on the renderer, and 0.8.0 recognised only the Roblox one. A browser React renderer builds the element from the tag as written, so "uicorner" reaches Slot and the same failure appeared there instead — the previews on this site among them. From 0.8.1 the lookup accepts both, and UIShadow joins the set it was missing from.
That second rule exists because Roblox attaches modifiers as children rather than properties, which is exactly the shape a Tailwind-style className transform emits when it lowers rounded-md or p-2 at the call site — vela-rbxts being the one this was built against:
<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 one to enhance.
This is what makes a class name on the part work: Vela emits its helper instances as the component’s children, and they arrive here as siblings of your element rather than as 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 correctly also becomes the focus-restore target and the positioning anchor for free, because 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, but only if your child actually accepts and forwards a ref to a real GuiObject. A wrapper component that drops ref will break focus restoration, positioning, and outside-press detection. Make custom host components forward their ref to the underlying element.
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 — a design-system button, 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.