Polymorphic Components
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.
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>
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} />;
});
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:
- TypeScript inference is fragile: Generic inference breaks with complex component trees, HOCs, and
forwardRef - Prop conflicts: What happens when your component's props collide with the target element's props? (
sizeon your Button vssizeoninput) - Bundle size: The polymorphic type definition is complex — it adds weight to
.d.tsfiles and slows down the TypeScript compiler - 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?
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>
);
}
as vs asChild: When to Use Which
| Concern | as prop | asChild + Slot | render prop (Base UI) |
|---|---|---|---|
| Type safety | Complex generics, fragile inference | Simple — child's types are its own | Simple — render target keeps its own types |
| Composability | Nesting polymorphic components is painful | Nesting works naturally | Nesting works naturally |
| Ref forwarding | Requires complex generic ref types | Ref goes on the child element directly | Ref goes on the render target directly |
| Prop merging | Automatic but collision-prone | Explicit via Slot — predictable | Explicit via render prop — predictable |
| DX for consumers | Fewer components to write | Slightly more verbose but clearer intent | Explicit render target, clear data flow |
| Library adoption | Chakra UI, Mantine | Radix UI, Ark UI, shadcn/ui | Base 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:
- 1Use polymorphism for design system primitives that might render as different elements (Button, Text, Box)
- 2Skip polymorphism for domain components that always render as a specific element (CourseCard, UserAvatar)
- 3Prefer asChild over as when building new components — the type safety is better
- 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 do | What 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 |
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?