Building components for a design system usually hits a wall the moment product
requirements get weird. Ideally, a Button should always be a <button>. But
in reality, we’re often asked to make something that looks like a button but
acts as a link (<a>) or even just a <span> inside a menu.
To handle this, we use polymorphism—usually through an as prop or the
asChild pattern you see in Radix UI.
Technically, there are two main ways to go about it. Using an as prop lets the
consumer pick any element they want, while asChild just passes props down to
an existing child. The problem is that building truly polymorphic components is
exhausting, especially when you get into the weeds with TypeScript. You end up
wrestling with generics, ComponentPropsWithoutRef, and the headache of
managing refs.
Often, instead of forcing a component to be everything to everyone, it makes
more sense to just limit the options. For example, you could restrict it so it
can only be a button, span, or label.
One crucial thing that gets overlooked is accessibility. This isn't just a
TypeScript technicality; it’s a direct risk to the product. When you change a
button to an <a>, the user's expectation for how Enter or Space works
changes. If you swap it for a div, you lose the original semantics unless you
manually rebuild the roles and keyboard navigation. Without being careful, you
might end up with a component that looks fine visually but is totally broken for
screen reader users.
Not every component needs to be polymorphic. I have a simple rule: if there hasn't been a real need to use more than two different elements with the same look in a single quarter, don't bother. If it’s only ever one element, just keep it fixed.
Sure, making a rigid wrapper feels less flexible. But to me, that cost is much lower than forcing the whole team to learn complex polymorphic types just to build a button. Polymorphism should be a solution for the product, not just a way to show off your TypeScript skills.