Skip to main content
  1. Frontends/
  2. React Guides/

Mastering Polymorphic Components in React with TypeScript: A 2025 Guide

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

If you’ve spent any time building a design system or a reusable UI library, you’ve hit this wall. You build a beautiful <Button> component. It has perfect padding, hover states, and focus rings. Then, a designer (or your product manager) points to a link in the navbar and says, “Make that look exactly like the button.”

Suddenly, your semantic <button> needs to behave like an <a> tag, but keep the styling of the button. Or maybe you need a <Text> component that renders as h1 on marketing pages and span inside cards.

This is where Polymorphic Components come into play.

In 2025, React ecosystems are stricter than ever regarding accessibility and type safety. While the concept of the as prop isn’t new, implementing it correctly with TypeScript—so that passing an href to a button throws a compile-time error while passing it to an <a> works perfectly—remains one of the trickier patterns to master.

In this guide, we aren’t just hacking it together with any. We’re going to build a production-grade, fully typed polymorphic component that supports refs and proper prop inference.

Prerequisites and Environment
#

Before we dive into the generics gymnastics, ensure your environment is set up for modern React development.

  • React: 18.3+ or 19.x
  • TypeScript: 5.x (We rely on advanced type inference)
  • IDE: VS Code (recommended for best IntelliSense support)

We don’t need external libraries like styled-components or emotion for the logic, though this pattern works seamlessly with them. We will stick to pure React and CSS modules/Tailwind logic for simplicity.

The Anatomy of Polymorphism
#

At its core, a polymorphic component accepts a prop (usually named as or component) that dictates which HTML element (or custom component) is rendered in the DOM.

Here is the high-level flow of how props need to be handled:

flowchart TD A[Input Props] --> B{Check 'as' Prop} B -- "Defaults (e.g., 'div')" --> C[Apply Default Element Types] B -- "Specified (e.g., 'a')" --> D[Apply Specific Element Types] C --> E[Merge Custom Props] D --> E E --> F[Filter Invalid Attributes] F --> G[Render Final DOM Element] style A fill:#e1f5fe,stroke:#01579b,stroke-width:2px style G fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#000 style B fill:#fff9c4,stroke:#fbc02d,stroke-width:2px,color:#000

The challenge isn’t rendering the element; it’s convincing TypeScript that the props you are passing are valid for that specific element.

Step 1: The Naive Implementation (And Why It Fails)
#

Let’s look at what most developers try first.

// ❌ Don't do this in production
import React from 'react';

type TextProps = {
  as?: React.ElementType;
  children: React.ReactNode;
  // allowing any other prop
  [key: string]: any; 
};

export const Text = ({ as: Component = 'span', children, ...rest }: TextProps) => {
  return <Component {...rest}>{children}</Component>;
};

The Problem: If you use <Text as="button" href="/home">Click me</Text>, TypeScript won’t complain about href being on a button (because of any), but React might warn you in the console, and semantically it’s garbage. Conversely, if you want IntelliSense for onClick or target, you won’t get it properly.

Step 2: Introducing Generics
#

To fix this, we need to tell TypeScript: “The props available on this component depend on what you passed into the as prop.”

We use a generic type parameter E that extends React.ElementType.

import React from 'react';

// 1. Capture the element type
type TextProps<E extends React.ElementType> = {
  as?: E;
  children: React.ReactNode;
};

// 2. Add Own Props (specific to your component)
type OwnProps<E extends React.ElementType> = TextProps<E> & 
  Omit<React.ComponentPropsWithoutRef<E>, keyof TextProps<E>>;

// 3. The Component
export const Text = <E extends React.ElementType = 'span'>({ 
  as, 
  children, 
  ...rest 
}: OwnProps<E>) => {
  const Component = as || 'span';
  return <Component {...rest}>{children}</Component>;
};

What’s happening here?

  1. E extends React.ElementType: E can be 'div', 'a', 'button', or another React component.
  2. React.ComponentPropsWithoutRef<E>: This extracts all native props for that element (e.g., href for a).
  3. Omit: We remove keys that might conflict with our own defined props (like as or children).

The Result:

// ✅ Valid
<Text as="a" href="https://google.com">Link</Text>

// ❌ Error: Property 'href' does not exist on type...
<Text as="button" href="https://google.com">Button</Text>

This is good, but it’s not “production ready” yet. We have a major blind spot: Refs.

Step 3: The forwardRef Challenge
#

This is where things get spicy. React.forwardRef is notoriously difficult to type with generics because type inference often breaks or defaults to unknown.

To support refs (e.g., focusing the button programmatically), we need a robust utility type. I’ve used this snippet in nearly every design system I’ve architected in the last two years.

The Utility Types
#

Create a file called types.ts or keep this at the top of your component file.

import React from 'react';

// Source of truth for the props the element supports
export type PropsOf<E extends React.ElementType> = React.ComponentPropsWithoutRef<E> & {
  as?: E;
};

// We explicitly strip out our custom props from the element's props to avoid collisions
// Omit is a standard TS utility, but sometimes we need a stricter version.
// For this guide, standard Omit is sufficient.

type PolymorphicProps<E extends React.ElementType, P = {}> = P &
  PropsOf<E> & {
    as?: E;
  };

// This is the magic sauce for forwardRef
export type PolymorphicComponentPropWithRef<
  E extends React.ElementType,
  P = {}
> = PolymorphicProps<E, P> & {
  ref?: React.ComponentPropsWithRef<E>['ref'];
};

The Full Implementation
#

Now, let’s build a Button component that uses these types.

import React from 'react';
import { PolymorphicComponentPropWithRef } from './types'; // assuming the above is in types.ts

// Define your component's specific props
type ButtonProps = {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
};

// The default element type
type ButtonComponent = <E extends React.ElementType = 'button'>(
  props: PolymorphicComponentPropWithRef<E, ButtonProps>
) => React.ReactNode; // React 19 might prefer React.JSX.Element

export const Button: ButtonComponent = React.forwardRef(
  <E extends React.ElementType = 'button'>(
    { as, variant = 'primary', size = 'md', isLoading, children, ...rest }: PolymorphicComponentPropWithRef<E, ButtonProps>,
    ref: React.ComponentPropsWithRef<E>['ref']
  ) => {
    
    const Component = as || 'button';
    
    // Example basic styling logic (replace with Tailwind/CSS Modules)
    const style = {
      padding: size === 'sm' ? '8px' : '16px',
      backgroundColor: variant === 'danger' ? 'red' : 'blue',
      opacity: isLoading ? 0.5 : 1,
      cursor: isLoading ? 'not-allowed' : 'pointer'
    };

    return (
      <Component
        ref={ref}
        style={style}
        {...rest}
      >
        {isLoading ? 'Loading...' : children}
      </Component>
    );
  }
);

Why this works
#

  1. Type Assertion: We cast the forwardRef result to ButtonComponent. This forces the generic signature that forwardRef normally strips away.
  2. Prop Spread: ...rest now correctly contains only props valid for E.
  3. Ref Forwarding: If as="a", the ref is typed as HTMLAnchorElement. If as="button", it is HTMLButtonElement.

Comparison: Approaches to Polymorphism
#

There are several ways to achieve this flexibility. Here is how our TypeScript generic approach compares to other methods.

Feature Custom Generics (Our Approach) React.cloneElement Styled-Components as
Type Safety ⭐⭐⭐⭐⭐ (Strict) ⭐ (Weak/None) ⭐⭐⭐⭐ (Good)
Runtime Performance High (Zero overhead) Medium (Cloning cost) Medium (Parsing cost)
Ref Support Manual (Harder setup) Complex Built-in
Bundle Size Tiny (Just code) Tiny Heavy (Requires lib)
Maintainability High (Once setup) Low (Implicit magic) Medium

Common Pitfalls and Performance
#

While powerful, polymorphic components are not a silver bullet. Here are the traps I see developers fall into.

1. The “Div Soup” Anti-Pattern
#

Just because you can make everything a polymorphic <Box> component doesn’t mean you should.

// ❌ Hard to read, hard to debug
<Box as="section">
  <Box as="h2">Title</Box>
  <Box as="p">Text</Box>
</Box>

Solution: Prefer semantic HTML tags for standard layout. Use polymorphism for interactive UI elements (Buttons, Cards, ListItems) where the visual style remains constant but behavior changes.

2. Performance on Large Unions
#

If you restrict as to a specific subset of elements (e.g., as?: 'a' | 'button'), TypeScript compiles faster. If you allow React.ElementType (which includes every HTML tag and every React component), your IDE’s type checker might lag slightly in massive projects.

Optimization: If a component should only ever be a link or a button, restrict generic E:

type AllowedElements = 'a' | 'button' | Link; // Next.js Link
// Use E extends AllowedElements instead of React.ElementType

3. Accessibility (ARIA) Mismatches
#

Changing the tag doesn’t automatically change the accessibility attributes.

  • If you render a div as a button (<Button as="div" onClick={...} />), you lose keyboard navigation and screen reader support.
  • Rule: If you change semantics to a non-interactive element, you must manually add tabIndex, role, and key handlers. Or better yet—don’t do that.

A Note on React 19 and Server Components
#

In the era of React Server Components (RSC), polymorphic components work perfectly fine because the as prop resolution happens during the render phase. Since we aren’t using useState or useEffect in the examples above (only props and refs), these components can technically remain Server Components unless they attach event handlers like onClick.

However, since Buttons usually have interaction, you will likely mark the usage file with 'use client' at the top. The type definitions themselves are environment-agnostic.

Conclusion
#

Building a truly reusable component library requires handling the edge cases where design and semantics diverge. Polymorphic components are the bridge between “It needs to look like this” and “It needs to act like that.”

By leveraging TypeScript’s extends, ComponentPropsWithRef, and a solid forwardRef casting strategy, you create a developer experience that is safe, discoverable, and robust.

Key Takeaways:

  1. Avoid the any trap; use Generics.
  2. Handle refs explicitly with utility types.
  3. Restrict the as prop if possible to improve type performance.
  4. Prioritize semantic HTML over convenient polymorphism.

Now, go refactor that Button component. Your future self (and your users) will thank you.


Further Reading
#