Latticeguides

asChild composition

Merge primitive behaviour onto your own host element with `asChild` and `Slot`.

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.

The problem asChild solves

Each part renders one specific instance class. Popover.Trigger renders a textbutton:

The part's own host element
<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:

Your element, primitive behavior
<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.
  • Event and Change handler 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.
Both Activated handlers run
<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.

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.Item and 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.

A custom Button as a dialog trigger
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>
);
}

When to use it

  • You need a different instance class than the part renders — an imagebutton where it renders a textbutton, a canvasgroup where it renders a frame.
  • 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 Content part, 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. Slot clones 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.