Composition with asChild
Use the asChild pattern to compose primitives with your own components.
The asChild pattern is the key to composing primitives with your own design system. No wrapper divs, no prop drilling — just clean composition.
The problem
You want a Dialog trigger, but it should be a link, not a button. Or you want a Popover trigger that's a custom component from your design system. Without composition, you'd need wrapper divs or forked components.
The solution: asChild
Every primitive element supports asChild. When true, the primitive doesn't render its own element. Instead, it passes all its props (ARIA attributes, event handlers, refs, data attributes) to the child element.
// Instead of wrapping:
<div onClick={open}>
<Dialog.Trigger>
<MyFancyButton>Open</MyFancyButton>
</Dialog.Trigger>
</div>
// Compose directly:
<Dialog.Trigger asChild>
<MyFancyButton>Open</MyFancyButton>
</Dialog.Trigger>// Instead of wrapping:
<div onClick={open}>
<Dialog.Trigger>
<MyFancyButton>Open</MyFancyButton>
</Dialog.Trigger>
</div>
// Compose directly:
<Dialog.Trigger asChild>
<MyFancyButton>Open</MyFancyButton>
</Dialog.Trigger>The MyFancyButton receives all of Dialog.Trigger's props (aria-haspopup, aria-expanded, onClick, data-state, etc.) merged with its own props.
Common patterns
Link as trigger
<Popover.Trigger asChild>
<a href="#" className="text-blue-600 underline">Show details</a>
</Popover.Trigger><Popover.Trigger asChild>
<a href="#" className="text-blue-600 underline">Show details</a>
</Popover.Trigger>Custom component as content
<Dialog.Content asChild>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="bg-white rounded-lg p-6"
>
<Dialog.Title>Animated dialog</Dialog.Title>
</motion.div>
</Dialog.Content><Dialog.Content asChild>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="bg-white rounded-lg p-6"
>
<Dialog.Title>Animated dialog</Dialog.Title>
</motion.div>
</Dialog.Content>Icon button as close
<Dialog.Close asChild>
<button aria-label="Close" className="absolute top-2 right-2">
<XIcon />
</button>
</Dialog.Close><Dialog.Close asChild>
<button aria-label="Close" className="absolute top-2 right-2">
<XIcon />
</button>
</Dialog.Close>Rules
Keep these rules in mind when using asChild:
asChildrequires exactly one child element.- The child must accept a
ref(useReact.forwardReffor custom components). - Event handlers compose (both the primitive's and the child's handlers fire).
- The child's props take precedence for non-handler, non-style props.
Building reusable styled components
A common pattern is to create styled wrappers around primitives:
import * as DialogPrimitive from '@gentleduck/primitives/dialog'
export const Dialog = DialogPrimitive.Root
export const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50" />
<DialogPrimitive.Content
ref={ref}
className={cn("fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6", className)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
))import * as DialogPrimitive from '@gentleduck/primitives/dialog'
export const Dialog = DialogPrimitive.Root
export const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 bg-black/50" />
<DialogPrimitive.Content
ref={ref}
className={cn("fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-6", className)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
))This gives you a styled component that still accepts all primitive props and supports asChild.