Lesson 3: The asChild Pattern
Master composition contracts: prop merging, ref forwarding, and safe custom components.
Lesson 3 of 10: asChild is the key to integrating primitives with your design-system components.
Why asChild matters
Without asChild, you either accept default HTML nodes or add wrappers that hurt semantics and styling.
With asChild, primitives attach behavior to your element directly.
<Dialog.Trigger asChild>
<a href="#" className="inline-flex items-center">Open</a>
</Dialog.Trigger><Dialog.Trigger asChild>
<a href="#" className="inline-flex items-center">Open</a>
</Dialog.Trigger>Merge contract
When asChild is enabled:
- primitive and child event handlers compose,
- refs compose,
- class/style merge,
- ARIA/data attributes attach to child.
This keeps behavior while preserving your visual control.
Ref-forwarding requirement
Custom child components must forward refs.
const ActionButton = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ className, ...props }, ref) => <button ref={ref} className={className} {...props} />,
)
ActionButton.displayName = 'ActionButton'const ActionButton = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ className, ...props }, ref) => <button ref={ref} className={className} {...props} />,
)
ActionButton.displayName = 'ActionButton'Then:
<Dialog.Trigger asChild>
<ActionButton>Open</ActionButton>
</Dialog.Trigger><Dialog.Trigger asChild>
<ActionButton>Open</ActionButton>
</Dialog.Trigger>Common failure modes
- Multiple children under
asChild. - Child component that drops incoming props.
- Child component that does not forward ref.
- Overriding
onClickand forgetting composed behavior.
If trigger behavior disappears, inspect rendered element and ensure primitive props are present.
Building your own asChild API
Use Slot for custom primitives:
import { Slot } from '@gentleduck/primitives/slot'
function Box({ asChild, ...props }: { asChild?: boolean } & React.ComponentProps<'div'>) {
const Comp = asChild ? Slot : 'div'
return <Comp {...props} />
}import { Slot } from '@gentleduck/primitives/slot'
function Box({ asChild, ...props }: { asChild?: boolean } & React.ComponentProps<'div'>) {
const Comp = asChild ? Slot : 'div'
return <Comp {...props} />
}This aligns your custom components with library conventions.
Lab
- Convert
Dialog.TriggerandDialog.Closeto design-system buttons viaasChild. - Replace trigger with a link and verify ARIA/data attributes still apply.
- Intentionally break ref forwarding and observe behavior, then fix it.