Latticeguides

asChild composition

Merge primitive behavior onto your own host element with asChild and the Slot component, when forwarding props onto the part's own element is not enough.

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.

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

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, 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.
  • Event and Change handler tables are chained. If both the primitive and your child define a handler for the same signal (say Activated), 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 Active or Selectable on a trigger).
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 your own analytics or sound effects to the same event the primitive uses for its behavior.

Where you’ll use it

asChild shows up on the parts that wrap a single interactive or visual host:

  • TriggersDialog.Trigger, Popover.Trigger, Select.Trigger, and friends, so your own button opens the surface.
  • Close buttonsDialog.Close, so a styled button inside the content closes it.
  • ItemsMenu.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 correctly also becomes the focus-restore target and the positioning anchor for free, because 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 — a design-system button, 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.