Primitive & asChild
How Radix UI's Primitive component and the asChild prop enable polymorphic component composition without wrapper divs
What is the Primitive pattern?
Every Radix component is built on a Primitive base. It renders a native HTML element
by default but can be swapped for any React component via the asChild prop.[?]
import * as Dialog from "@radix-ui/react-dialog";
// Default: renders a <button>
<Dialog.Trigger>Open</Dialog.Trigger>
// asChild: renders YOUR component
<Dialog.Trigger asChild>
<MyFancyButton>Open</MyFancyButton>
</Dialog.Trigger>The Slot mechanism
Under the hood, asChild uses @radix-ui/react-slot. The Slot component
merges its props onto the child element rather than wrapping it.
How Slot merges props
When Slot renders, it:
- Takes the single child element
- Merges its own props onto the child (className concatenation, event handler chaining)
- Forwards the ref to the child
import { Slot } from "@radix-ui/react-slot";
function Primitive({ asChild, ...props }) {
const Comp = asChild ? Slot : "div";
return <Comp {...props} />;
}Why this matters
The Primitive pattern solves a fundamental tension in component libraries: the library needs to render HTML for accessibility, but the consumer needs control over the actual DOM element for styling and composition.[?] This is the essence of polymorphism in component design.
Without asChild
// Extra wrapper div breaks your layout
<Dialog.Trigger>
<button className="my-button">Open</button>
</Dialog.Trigger>
// Renders: <button><button class="my-button">Open</button></button> — BADWith asChild
// No wrapper — props merge onto your element
<Dialog.Trigger asChild>
<button className="my-button">Open</button>
</Dialog.Trigger>
// Renders: <button class="my-button">Open</button> — GOODImplementation walkthrough
Let's trace through the actual Radix source to understand the full pattern.
Step 1: The Primitive component
At the core of every Radix primitive is a simple factory that creates element-specific components:
const NODES = [
"a",
"button",
"div",
"form",
"h2",
"h3",
"img",
"input",
"label",
"li",
"nav",
"ol",
"p",
"span",
"svg",
"ul",
] as const;
type Primitives = {
[E in (typeof NODES)[number]]: PrimitiveForwardRefComponent<E>;
};
const Primitive: Primitives = NODES.reduce((primitives, node) => {
const Node = React.forwardRef((props, ref) => {
const { asChild, ...primitiveProps } = props;
const Comp = asChild ? Slot : node;
return <Comp {...primitiveProps} ref={ref} />;
});
return { ...primitives, [node]: Node };
}, {} as Primitives);Step 2: Slot's mergeProps logic
The magic happens in how Slot merges props from the parent onto the child:
function mergeProps(slotProps, childProps) {
const overrideProps = { ...childProps };
for (const propName in childProps) {
const slotValue = slotProps[propName];
const childValue = childProps[propName];
if (/^on[A-Z]/.test(propName)) {
// Chain event handlers
if (slotValue && childValue) {
overrideProps[propName] = (...args) => {
childValue(...args);
slotValue(...args);
};
} else {
overrideProps[propName] = slotValue || childValue;
}
} else if (propName === "style") {
// Merge style objects
overrideProps[propName] = { ...slotValue, ...childValue };
} else if (propName === "className") {
// Concatenate classNames
overrideProps[propName] = [slotValue, childValue]
.filter(Boolean)
.join(" ");
}
}
return { ...slotProps, ...overrideProps };
}Step 3: Ref forwarding through Slot
Slot also composes refs so both the parent and child receive
the DOM reference:
function composeRefs(...refs) {
return (node) => {
refs.forEach((ref) => {
if (typeof ref === "function") {
ref(node);
} else if (ref != null) {
ref.current = node;
}
});
};
}Key takeaways
- Primitive renders native elements — zero overhead by default
- asChild delegates to Slot — one prop flips the rendering strategy
- Slot merges, not wraps — events chain, classNames concatenate, styles merge
- Refs compose — both parent and child get the DOM node
- This is the foundation — every Radix component you'll study builds on this pattern