# 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.

Source: https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition/

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.

> **You no longer need asChild just to style a part**
>
> 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`:

```tsx title="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:

```tsx title="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).

```tsx title="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.

> **asChild takes exactly one GuiObject — plus modifiers**
>
> `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 creatable `UIComponent` classes 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](https://docs.astra-void.xyz/vela-rbxts/index.md) being the one this was built against:
>
> ```tsx title="A modifier sibling is re-parented, not cloned"
> <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](https://docs.astra-void.xyz/lattice-ui/guides/styling-with-vela.md) 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.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.

```tsx title="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>
  );
}
```

> **Your host must forward its ref**
>
> `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 `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.

## Related

- [Composition model](https://docs.astra-void.xyz/lattice-ui/getting-started/composition-model.md)
- [Dialog](https://docs.astra-void.xyz/lattice-ui/components/dialog.md)
- [Popover](https://docs.astra-void.xyz/lattice-ui/components/popover.md)
- [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md)
- [Runtime reference](https://docs.astra-void.xyz/lattice-ui/reference/runtime.md)
