Component patterns (2): Polymorphism

When building design systems, flexibility is key. I want components that can adapt to different use cases while still looking and behaving the same. This is where polymorphic components come in. A polymorphic component can render different underlying HTML elements but keep consistent styling, logic, and accessibility features.

A common example is a Button. By default, it may render a <button>, but in some cases you might want it to render an <a> for navigation, or even a custom Link component from a framework like Next.js. Instead of building three different components, a single polymorphic Button can handle all these cases.

This approach not only reduces duplication but also helps enforce semantic HTML. A Button that can render as a link makes it easier to keep the right element for the right job. It also makes APIs more reusable across our libraries and applications.

Different implementation approaches

There are two common strategies for implementing polymorphic components. The first is the as prop. Here you pass the element or component you want to render as a prop:

<Button as="a" href="/pricing" />
<Button as={NextLink} href="/pricing" />

This approach is simple and familiar. However, it struggles with TypeScript performance in large projects, and type inference often breaks down. Prop collisions can also occur, and logic may become harder to follow when complexity increases.

The second approach is the asChild prop. This pattern, inspired by Radix UI’s Slot component, lets you wrap the Button around the actual element:

<Button asChild>
  <a href="/pricing">Pricing</a>
</Button>

This version is slightly more verbose, but it avoids type issues and prop conflicts. It works well with routing libraries like Next.js or React Router, and it promotes cleaner composition by separating primitives from rendered elements.

Composing multiple behaviors

One powerful aspect of asChild is the ability to compose multiple primitives without introducing unnecessary wrapper elements. For example, a single button can serve as both a Tooltip trigger and a Dialog trigger. Because each primitive attaches behavior directly to the same element, styling, refs, and accessibility roles remain intact.

This level of composition makes design systems more predictable. It also avoids deep DOM nesting, which improves both performance and accessibility.

Conclusion

After testing both strategies, the asChild pattern looks like a more robust solution to me. That is why I recommend it as the standard pattern to adopt in all our components, including the children that they might export.

Extra ball

The Base UI team realized that React render props more than enough to solve this issue, so here is their approach: Base UI docs: Composition

Personally, I only use the render prop when I expect the children to be a function. See the React docs: Passing data with a render prop.

<Switch.Thumb
  render={(props, state) => (
    <span {...props}>
      {state.checked ? <CheckedIcon /> : <UncheckedIcon />}
    </span>
  )}
/>

That said, I wouldn’t oppose at all using the same approach as Base UI, as is a perfectly valid solution.

So, which one do you prefer?