Radix Foundations — Chapter 1

Primitive & asChild

How Radix UI's Primitive component and the asChild prop enable polymorphic component composition without wrapper divs

2 min read·Updated 2026-03-07
Section 1

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.[?]

button.tsx
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>
Section 2

The Slot mechanism

DialogComposedradix-ui/react-dialog
Dialog anatomy

Under the hood, asChild uses @radix-ui/react-slot. The Slot component merges its props onto the child element rather than wrapping it.

Interactive playground
const App = () => "Hello Jing!"; export default App;

How Slot merges props

When Slot renders, it:

  1. Takes the single child element
  2. Merges its own props onto the child (className concatenation, event handler chaining)
  3. 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} />;
}
Section 3

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> — BAD

With 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> — GOOD
radix-ui/react-presence/Presence.tsxL42-549
RecommendedUnderstanding React PresenceLearn how exit animations work under the hood.
Section 4

Implementation 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

3Dialog lifecycle

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;
      }
    });
  };
}
Section 5

Key takeaways

  1. Primitive renders native elements — zero overhead by default
  2. asChild delegates to Slot — one prop flips the rendering strategy
  3. Slot merges, not wraps — events chain, classNames concatenate, styles merge
  4. Refs compose — both parent and child get the DOM node
  5. This is the foundation — every Radix component you'll study builds on this pattern
Appendix

References (5)

Source
Sect.
asChild
G
§1
Polymorphism
G
§3
Radix Primitives Documentation
A
§1
React Docs — Composition vs Inheritance
A
§3