# Composition

URL: https://ark-ui.com/docs/react/guides/composition
LLM: https://ark-ui.com/llms.txt/guides/composition

Learn how to compose default components with custom elements

---

---
id: composition
title: Composition
description: Learn how to compose default components with custom elements
---

## The render Prop

Every Ark part renders a sensible default element. When you want your own — a design system `Button`, a router `Link` —
`render` puts it there instead, and hands you the part's props to apply.

```tsx
import { Popover } from '@ark-ui/react/popover'

export const Render = () => (
  <Popover.Root>
    <Popover.Trigger render={<button type="button">Open Popover</button>} />
    <Popover.Positioner>
      <Popover.Content>Content</Popover.Content>
    </Popover.Positioner>
  </Popover.Root>
)
```

The `Popover.Trigger` no longer renders its own `button`. Yours takes its place, with the trigger's props, event
handlers and ARIA attributes applied to it.

Each framework spells this the way that framework composes:

| Framework | Shape                                                   |
| --------- | ------------------------------------------------------- |
| React     | a `render` prop, taking an element or a function        |
| Solid     | a `render` prop, taking a function                      |
| Vue       | a `render` slot, written `#render` directly on the part |
| Svelte    | a `render` snippet                                      |

The capability is the same in all four: each one hands you the part's props to apply, and the part's state.

### Merging your own props

Applying the part's props and then adding your own replaces them rather than combining them. A trigger given your
`onClick` stops toggling, a part told to animate loses the custom properties driving it, and nothing warns you.

In React, pass an element rather than a function and the factory merges for you:

```tsx
<Popover.Trigger render={<Button className="my-button" onClick={handleClick} />} />
```

In Solid, `render` receives a function rather than a props object. Call it to get the part's props, passing your own to
merge them in:

```tsx
<Popover.Trigger render={(props) => <Button {...props({ class: 'my-button', onClick: handleClick })} />} />
```

Props that belong to your component rather than the element — a `value`, an `item` — stay outside the call:

```tsx
<Listbox.Item render={(props) => <ListVirtualizer.Item {...props()} item={virtualItem()} />} />
```

## Reading the part's state

The function form receives the part's state alongside its props, so your element can render from what the machine knows
rather than from a CSS attribute selector.

```tsx
import { Popover } from '@ark-ui/react/popover'
import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'

export const RenderState = () => (
  <Popover.Root>
    <Popover.Trigger
      render={(props, state) => (
        <button type="button" {...props}>
          {state.open ? 'Close' : 'Open'} Popover
          {state.open ? <ChevronUpIcon /> : <ChevronDownIcon />}
        </button>
      )}
    />
    <Popover.Positioner>
      <Popover.Content>Content</Popover.Content>
    </Popover.Positioner>
  </Popover.Root>
)
```

`Popover.Trigger` gives you `open`, `current` and `value`. Each part publishes its own shape — `Checkbox.Root` has
`checked` and `indeterminate`, `Accordion.Item` has `expanded` and `focused` — and the type comes with it, so
`state.open` is checked rather than guessed.

This is the difference between `render` and `asChild`. `asChild` could only place your element; `render` tells it what
the part is doing.

## The asChild Prop

`asChild` does the same job by wrapping the element as a child instead of passing it to `render`.

```tsx
import { Popover } from '@ark-ui/react/popover'

export const AsChild = () => (
  <Popover.Root>
    <Popover.Trigger asChild>
      <button>Open Popover</button>
    </Popover.Trigger>
    <Popover.Positioner>
      <Popover.Content>Content</Popover.Content>
    </Popover.Positioner>
  </Popover.Root>
)
```

It still works, and it is deprecated. Prefer `render` in new code — it is explicit about which element is being
replaced, and it doesn't change how children are interpreted.

## The Ark Factory

You can use the `ark` factory to create your own elements that work just like Ark UI components.

```tsx
import { ark } from '@ark-ui/react/factory'

export const Factory = () => <ark.span render={<a href="#">Ark UI</a>} />
```

The factory renders the child element in place of the `span`, so this produces:

```html
<a href="#">Ark UI</a>
```

Any props you pass to `ark.span` are merged onto the child element, which is how the factory forwards styling and
behavior to whatever you render.

## ID Composition

When composing components that need to work together, share IDs between them using the `ids` prop for proper
accessibility and interaction.

```tsx
import { Avatar } from '@ark-ui/react/avatar'
import { Tooltip } from '@ark-ui/react/tooltip'
import { useId } from 'react'

export const TooltipWithAvatar = () => {
  const id = useId()

  return (
    <Tooltip.Root ids={{ trigger: id }}>
      <Tooltip.Trigger asChild>
        <Avatar.Root ids={{ root: id }}>
          <Avatar.Image src="https://bit.ly/sage-adebayo" />
          <Avatar.Fallback>SA</Avatar.Fallback>
        </Avatar.Root>
      </Tooltip.Trigger>
      <Tooltip.Positioner>
        <Tooltip.Content>Segun Adebayo is online</Tooltip.Content>
      </Tooltip.Positioner>
    </Tooltip.Root>
  )
}
```

Both components share the same `id` through their `ids` props, creating proper accessibility bindings, `aria-*`
attributes and interaction behavior.

## Limitations

Render one element. `render` and `asChild` both replace a single element, so returning a fragment or several siblings
will not work.

Certain components, such as `Checkbox.Root` or `RadioGroup.Item`, have specific requirements for their child elements.
For instance, they may require a label element as a child. If you change the underlying element type, ensure it remains
accessible and functional.

Pass one or the other. Giving a part both `render` and `asChild` is a mistake. React throws in development, Solid warns,
and Vue and Svelte use the `render` form.
