Skip to content

Polymorphic Components

advanced15 min read

The Rendering Flexibility Problem

You build a Button component. It looks great. Then a designer says: "Make that button a link." You have two bad options: duplicate the component as LinkButton, or add an href prop that conditionally renders an anchor. Both scale terribly. What about rendering as a next/link? Or as a React Router Link? Or as a div with role="button" for a drag handle?

Polymorphic components solve this: one component, any underlying element or component, full type safety.

Mental Model

Think of polymorphic components like a universal adapter. You have a charger (your component) that works with any outlet (HTML element or React component). The adapter changes the physical shape (rendered element) but the electricity (behavior and styling) stays the same.

The as Prop Pattern

The classic approach. Pass an as prop to change what element the component renders:

<Button as="a" href="/about">About</Button>       // renders <a>
<Button as="button" onClick={save}>Save</Button>   // renders <button>
<Button as={Link} to="/dashboard">Go</Button>       // renders React Router Link

The basic implementation is simple. The TypeScript is where it gets interesting.

Naive Implementation (No Type Safety)

function Button({ as: Component = "button", ...props }: any) {
  return <Component {...props} />;
}

// PROBLEM: TypeScript allows nonsense
<Button as="a" onClick={123} fakeProp="whatever" />
// No errors — no type safety at all

This works at runtime but offers zero type safety. You can pass onClick={123} and TypeScript stays silent. Useless.

Type-Safe Implementation

type PolymorphicProps<E extends ElementType, P = {}> = P &
  Omit<ComponentPropsWithoutRef<E>, keyof P> & {
    as?: E;
  };

type ButtonOwnProps = {
  variant?: "primary" | "secondary" | "ghost";
  size?: "sm" | "md" | "lg";
};

type ButtonProps<E extends ElementType = "button"> = PolymorphicProps<E, ButtonOwnProps>;

function Button<E extends ElementType = "button">({
  as,
  variant = "primary",
  size = "md",
  ...props
}: ButtonProps<E>) {
  const Component = as || "button";
  return (
    <Component
      className={buttonVariants({ variant, size })}
      {...props}
    />
  );
}

Now TypeScript enforces the correct props for whatever element you are rendering as:

// TypeScript knows href belongs to <a>
<Button as="a" href="/about">About</Button>

// TypeScript ERROR: href is not valid on <button>
<Button href="/about">About</Button>

// TypeScript ERROR: Property 'fakeProp' does not exist
<Button as="a" fakeProp="nope">Link</Button>
Quiz
What does ComponentPropsWithoutRef accomplish in the polymorphic type?

Adding Ref Forwarding

The as prop gets more complex when you need ref forwarding. The ref type must change based on the as target.

React 19: ref as a regular prop

In React 19, forwardRef is deprecated. Components receive ref as a regular prop, which makes polymorphic ref forwarding much simpler:

type PolymorphicRef<E extends ElementType> = ComponentPropsWithRef<E>["ref"];

type PolymorphicPropsWithRef<E extends ElementType, P = {}> = PolymorphicProps<E, P> & {
  ref?: PolymorphicRef<E>;
};

function Button<E extends ElementType = "button">({
  as,
  variant = "primary",
  size = "md",
  ref,
  ...props
}: PolymorphicPropsWithRef<E, ButtonOwnProps>) {
  const Component = as || "button";
  return <Component ref={ref} className={buttonVariants({ variant, size })} {...props} />;
}
const linkRef = useRef<HTMLAnchorElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);

<Button as="a" ref={linkRef}>Link</Button>       // ref is HTMLAnchorElement
<Button ref={buttonRef}>Click</Button>             // ref is HTMLButtonElement

No wrapper, no generic inference headaches. The ref is just a prop.

Legacy approach (React 18 and earlier)

Before React 19, you needed forwardRef to pass refs through components. This still works but is no longer recommended:

const Button = forwardRef(function Button<E extends ElementType = "button">(
  { as, variant = "primary", size = "md", ...props }: ButtonProps<E>,
  ref: PolymorphicRef<E>,
) {
  const Component = as || "button";
  return <Component ref={ref} className={buttonVariants({ variant, size })} {...props} />;
});
Common Trap

With the legacy forwardRef approach, TypeScript cannot always infer the generic type parameter. In some cases, the ref type will fall back to any. This is a known limitation of TypeScript's generic inference with higher-order functions. React 19's ref-as-prop eliminates this problem entirely.

The DX Problem with as

The as prop works, but it has real problems at scale:

  1. TypeScript inference is fragile: Generic inference breaks with complex component trees, HOCs, and forwardRef
  2. Prop conflicts: What happens when your component's props collide with the target element's props? (size on your Button vs size on input)
  3. Bundle size: The polymorphic type definition is complex — it adds weight to .d.ts files and slows down the TypeScript compiler
  4. Composability: Wrapping a polymorphic component in another polymorphic component compounds the type complexity
// PROP COLLISION: 'size' means two different things
<Button as="input" size="lg" />
// Is size="lg" your Button's size variant or the input's size attribute?
Quiz
A polymorphic Button has a size prop with values 'sm' | 'md' | 'lg'. You render it as='input'. What happens with the size prop?

The asChild Pattern (Modern Alternative)

Radix UI introduced asChild as a cleaner solution. Instead of the component deciding what to render, the consumer passes their own element as a child, and the component merges its behavior onto it:

// Traditional as prop
<Button as="a" href="/about">About</Button>

// asChild — consumer provides the element
<Button asChild>
  <a href="/about">About</a>
</Button>

// asChild with a custom component
<Button asChild>
  <Link href="/dashboard">Dashboard</Link>
</Button>

How asChild Works: The Slot Component

The magic is a Slot component that merges props from the parent onto the child:

import { cloneElement, isValidElement, type ReactNode } from "react";

interface SlotProps {
  children: ReactNode;
  [key: string]: unknown;
}

function Slot({ children, ...slotProps }: SlotProps) {
  if (!isValidElement(children)) {
    return null;
  }

  const childProps = children.props as Record<string, unknown>;

  const mergedProps: Record<string, unknown> = { ...slotProps };

  for (const key of Object.keys(childProps)) {
    if (key === "className") {
      mergedProps.className = cn(
        slotProps.className as string,
        childProps.className as string,
      );
    } else if (key === "style") {
      mergedProps.style = { ...(slotProps.style as object), ...(childProps.style as object) };
    } else if (key.startsWith("on") && typeof slotProps[key] === "function") {
      mergedProps[key] = (...args: unknown[]) => {
        (childProps[key] as Function)?.(...args);
        (slotProps[key] as Function)?.(...args);
      };
    } else {
      mergedProps[key] = childProps[key] ?? slotProps[key];
    }
  }

  return cloneElement(children, mergedProps);
}

Then the Button component uses Slot conditionally:

interface ButtonProps {
  variant?: "primary" | "secondary" | "ghost";
  size?: "sm" | "md" | "lg";
  asChild?: boolean;
  children: ReactNode;
}

function Button({ variant = "primary", size = "md", asChild, children, ...props }: ButtonProps) {
  const Component = asChild ? Slot : "button";

  return (
    <Component className={buttonVariants({ variant, size })} {...props}>
      {children}
    </Component>
  );
}
Quiz
Why does the Slot component merge className values instead of overriding them?

as vs asChild: When to Use Which

Concernas propasChild + Slotrender prop (Base UI)
Type safetyComplex generics, fragile inferenceSimple — child's types are its ownSimple — render target keeps its own types
ComposabilityNesting polymorphic components is painfulNesting works naturallyNesting works naturally
Ref forwardingRequires complex generic ref typesRef goes on the child element directlyRef goes on the render target directly
Prop mergingAutomatic but collision-proneExplicit via Slot — predictableExplicit via render prop — predictable
DX for consumersFewer components to writeSlightly more verbose but clearer intentExplicit render target, clear data flow
Library adoptionChakra UI, MantineRadix UI, Ark UI, shadcn/uiBase UI (MUI), shadcn/ui

The industry has moved away from the as prop. Radix pioneered asChild, shadcn/ui popularized it, and Ark UI adopted it. Base UI (v1.0 stable Dec 2025) took a different approach with a render prop: <Button render={<Link href="/..." />} />. Both asChild and render are better than as because the consumer's element keeps its own types.

When Polymorphism is Overkill

Not every component needs to be polymorphic. Here is the decision framework:

Key Rules
  1. 1Use polymorphism for design system primitives that might render as different elements (Button, Text, Box)
  2. 2Skip polymorphism for domain components that always render as a specific element (CourseCard, UserAvatar)
  3. 3Prefer asChild over as when building new components — the type safety is better
  4. 4If your component only needs to render as 2-3 elements, use a discriminated union instead of full polymorphism
// FOR 2-3 VARIANTS: use a discriminated union — simpler than full polymorphism
type ButtonProps =
  | { as?: "button"; onClick: () => void; href?: never }
  | { as: "a"; href: string; target?: string; onClick?: never }
  | { as: typeof Link; href: string; onClick?: never };

// FOR MANY VARIANTS: use asChild — let the consumer decide
<Button asChild>
  <CustomComponent customProp="value">Click</CustomComponent>
</Button>
What developers doWhat they should do
Making every component polymorphic just in case
Polymorphism adds type complexity and cognitive overhead. A CourseCard will never render as an input element. Keep it simple.
Only add polymorphism to components that genuinely render as different elements
Using the as prop and then manually restricting which elements are allowed
Restricting the as prop defeats the purpose of polymorphism and adds maintenance burden. If you know the valid targets, a discriminated union is cleaner.
Use a constrained union type for known variants, or asChild for unlimited flexibility
Forgetting to forward the ref when building polymorphic components
Consumers often need refs for focus management, measurements, or library integration. A polymorphic component without ref forwarding breaks common use cases.
Always use forwardRef (or the ref prop in React 19) with polymorphic components
Interview Question

You are building a design system Text component that can render as any heading level (h1-h6), a paragraph, a span, or a label. It has a variant prop for visual style and an as prop for the HTML element. How would you type this in TypeScript? How do you handle the case where as='label' requires an htmlFor prop but as='h1' does not? Would you use as or asChild? Why?