---
name: primitive-and-slot
metadata:
  type: core
  domain: composition
requires: [loke-ui]
description: >
  Primitive element system (17 HTML types) with asChild composition via Slot.
  createSlot, createSlottable, Slottable for compound slot patterns.
  dispatchDiscreteCustomEvent for React 18 batching. Prop merge order: handlers
  composed (child first then slot), style merged (child wins), className
  concatenated, all other props child-wins. Mental model: asChild means "render
  whatever you give me but behave like [element]."
---

# Primitive and Slot

## Setup

```tsx
import { Primitive } from "@loke/ui/primitive";

// Basic usage — renders a <button> with all standard button props
function SaveButton(props: React.ComponentPropsWithoutRef<typeof Primitive.button>) {
  return <Primitive.button type="button" {...props} />;
}
```

```tsx
// asChild — render the child element but apply Primitive's behavior
import { Primitive } from "@loke/ui/primitive";

function Trigger({ asChild, ...props }: { asChild?: boolean } & React.ComponentPropsWithoutRef<typeof Primitive.button>) {
  return <Primitive.button asChild={asChild} {...props} />;
}

// Caller passes their own element; Primitive merges props onto it
<Trigger asChild>
  <a href="/settings">Settings</a>
</Trigger>
// Renders: <a href="/settings" type="button"> — button props merge onto <a>
```

## Core Patterns

### asChild composition

`asChild` tells Primitive to clone the child element rather than render the default HTML tag. The child element's props win for everything except event handlers (composed) and style/className (merged).

```tsx
import { Primitive } from "@loke/ui/primitive";

interface ButtonProps extends React.ComponentPropsWithoutRef<typeof Primitive.button> {
  asChild?: boolean;
}

function Button({ asChild, ...props }: ButtonProps) {
  return <Primitive.button asChild={asChild} {...props} />;
}

// Renders <a> with onClick from both Button and <a>
<Button asChild onClick={() => console.log("button handler")}>
  <a href="/" onClick={() => console.log("link handler")}>
    Home
  </a>
</Button>
```

### Slottable for compound slot patterns

When a component wraps children and also needs to accept an `asChild` target, use `Slottable` to mark which child is the slot target. Props from the outer component flow onto the element inside `<Slottable>`.

```tsx
import { createSlot, createSlottable } from "@loke/ui/slot";

const MySlot = createSlot("MyComponent");
const MySlottable = createSlottable("MyComponent");

function MyComponent({
  asChild,
  children,
  ...props
}: {
  asChild?: boolean;
  children: React.ReactNode;
} & React.HTMLAttributes<HTMLElement>) {
  const Comp = asChild ? MySlot : "div";
  return (
    <Comp {...props}>
      <span className="icon" />
      <MySlottable>{children}</MySlottable>
    </Comp>
  );
}

// The <button> receives MyComponent's props; the icon stays as a sibling
<MyComponent asChild className="wrapper">
  <button>Click me</button>
</MyComponent>
// Renders: <button class="wrapper"><span class="icon" />Click me</button>
```

### dispatchDiscreteCustomEvent for React 18 batching

React 18 batches all event handler updates, including custom events dispatched inside discrete events (click, pointerdown, keydown). Custom events don't carry a React priority, so their updates can be batched unexpectedly. Use `dispatchDiscreteCustomEvent` to flush synchronously.

```tsx
import { dispatchDiscreteCustomEvent } from "@loke/ui/primitive";

function SelectItem({ onSelect }: { onSelect?: () => void }) {
  return (
    <div
      onPointerDown={(event) => {
        // Dispatching a custom event inside a discrete event — must flush
        dispatchDiscreteCustomEvent(
          event.currentTarget,
          new CustomEvent("select.item", { bubbles: true, cancelable: true }),
        );
      }}
    />
  );
}
```

Do NOT use this utility for:
- Known event types (`new Event('click')`) — React already handles priority
- Custom events inside non-discrete events (scroll, mousemove) — batching is fine there
- Focus/blur events — they can fire implicitly during render

### Prop merge order

When `asChild` is active, Slot merges slot props (from Primitive) with child props using these rules:

| Prop type | Rule |
|---|---|
| Event handlers (`on*`) | Both called; child handler runs first, then slot handler |
| `style` | Merged as objects; child wins on conflicts |
| `className` | Concatenated: `"${slotClass} ${childClass}"` |
| Everything else | Child wins |

```tsx
// Demonstrating merge behavior
<Primitive.button
  asChild
  onClick={() => console.log("slot")}
  className="slot-class"
  style={{ color: "red" }}
  data-testid="overridden-by-child"
>
  <a
    onClick={() => console.log("child")}   // fires first
    className="child-class"               // appended after slot-class
    style={{ color: "blue" }}             // wins over slot's red
    data-testid="child-value"             // wins
  />
</Primitive.button>
// Output className: "slot-class child-class"
// Output style: { color: "blue" }
// Output data-testid: "child-value"
// onClick fires: "child" then "slot"
```

## Available Primitive Elements

`a`, `button`, `div`, `form`, `h2`, `h3`, `img`, `input`, `label`, `li`, `nav`, `ol`, `p`, `select`, `span`, `svg`, `ul`

## Common Mistakes

### 1. Multiple children with asChild

`asChild` requires exactly one child element. Multiple children cause `React.Children.only` to throw.

```tsx
// WRONG — throws at runtime
<Primitive.button asChild>
  <span>Icon</span>
  <span>Label</span>
</Primitive.button>

// CORRECT — wrap in a single element
<Primitive.button asChild>
  <span><span>Icon</span><span>Label</span></span>
</Primitive.button>
```

Source: `slot.tsx` — `SlotClone` calls `React.Children.only(null)` when count > 1.

### 2. Expecting child props to be overridden by slot props

The merge rules mean child props win for non-handler, non-style, non-className props. If you need slot props to take precedence, you must not pass conflicting props from the child.

```tsx
// WRONG — expects disabled to come from Primitive, but child's disabled=false wins
<Primitive.button asChild disabled>
  <a disabled={false}>Link</a>
</Primitive.button>
// Result: disabled=false (child wins)

// CORRECT — only pass what you intend on the child
<Primitive.button asChild disabled>
  <a>Link</a>
</Primitive.button>
```

Source: `slot.tsx` — `mergeProps` spreads `{ ...slotProps, ...overrideProps }` where `overrideProps` starts as `{ ...childProps }`.

### 3. Custom events without dispatchDiscreteCustomEvent inside discrete handlers

Omitting `dispatchDiscreteCustomEvent` when dispatching a custom event inside a click/pointerdown handler can cause state updates to batch unexpectedly, producing stale UI between the event firing and React re-rendering.

```tsx
// WRONG — update may batch, trigger may remain "open" for a frame
onPointerDown={(e) => {
  e.currentTarget.dispatchEvent(new CustomEvent("dismiss"));
}}

// CORRECT
onPointerDown={(e) => {
  dispatchDiscreteCustomEvent(e.currentTarget, new CustomEvent("dismiss"));
}}
```

Source: `primitive.tsx` — comment citing React batching PR #1378.

### 4. Confusing asChild with Headless UI's `as` prop

`as` in Headless UI replaces the rendered tag but does not merge props — it accepts a component type. `asChild` in `@loke/ui` accepts a child element instance and merges props onto it. They are not equivalent.

```tsx
// Headless UI pattern (NOT @loke/ui)
<HeadlessButton as={Link} href="/home" />

// @loke/ui pattern
<Primitive.button asChild>
  <Link href="/home" />
</Primitive.button>
```
