Composition
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.
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:
<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:
<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:
<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.
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.
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.
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:
<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.
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.