Polymorphic Components with the as Prop in React
AI generated
</>
{ }
React · TypeScript · Component Design
Polymorphic Components with the as Prop
flexible and type safe in React and TypeScript

Polymorphic components let the caller decide which HTML element actually gets rendered, without the component itself needing a separate variant for it. With the as prop and TypeScript's ElementType, this flexibility stays fully type safe, including correctly inherited props and working ref forwarding.

18 min read as Prop · ElementType · ComponentPropsWithoutRef React 19 · TypeScript 5

1. What polymorphic components actually solve

A polymorphic component renders a different HTML element or component depending on a prop it receives, without its outer behavior or props changing in the process. The classic case: a Button should sometimes render as a <button>, sometimes as an <a>, and sometimes as a React Router Link, but always look and behave identically. Without polymorphic components, you would have to maintain three separate components that differ only in their root element.

Design systems run into this problem especially often, because buttons, text, and layout containers need different elements for semantic reasons. A heading should optionally render as h1 through h6, a text building block sometimes as span, sometimes as p, sometimes as label. Polymorphic components with the as prop solve exactly this problem: a single implementation, a single set of visual props, but free choice of the actual DOM element by the caller.

2. The as prop core principle

The core principle of a polymorphic component is simple: a prop called as accepts an element name or a component, and the implementation dynamically renders exactly that element. In plain JavaScript, this means storing the value of as in a capitalized variable, because JSX interprets capitalized identifiers as component references and lowercase ones as native HTML elements. That variable is then used in place of a fixed tag name in the JSX.

The actual value of polymorphic components only shows once all the standard props of the respective element are also passed through correctly. A button rendered with as="a" must accept href, while the same button rendered with as="button" should instead accept type="submit". Without clean typing, you either lose type safety entirely or have to manually guard every combination, which quickly becomes unwieldy as the number of supported elements grows.


// Text.jsx — minimal polymorphic component, plain JavaScript version
function Text({ as: Component = "span", className, children, ...rest }) {
  return (
    <Component className={className} {...rest}>
      {children}
    </Component>
  );
}

// Usage — same visual component, different rendered elements
// <Text>Default span</Text>
// <Text as="p" className="lead">Rendered as a paragraph</Text>
// <Text as="label" htmlFor="email">Rendered as a label</Text>
// <Text as={Link} to="/pricing">Rendered as a router Link</Text>

3. Typing with ElementType

In TypeScript, a type safe polymorphic component starts with the built in type ElementType. It accepts both string literals such as "button" or "div" and React component references. The component's generic parameter is bound to ElementType, so TypeScript knows at every call site which concrete element is currently active and demands the matching props for it.

Without ElementType, you would either have to fall back to string, which gives up all type safety for the props, or write a separate overload signature for every supported element, which quickly becomes unmaintainable for polymorphic components with many possible elements. ElementType gives you exactly the right balance: generic enough for arbitrary elements, but precise enough to automatically offer onClick with the correct event type when as="button".


// polymorphic-types.ts — the ElementType foundation
import type { ElementType, ComponentPropsWithoutRef } from "react";

// Generic constraint: C must be a valid tag name or component
type PolymorphicProps<C extends ElementType, Props = object> = Props & {
  as?: C;
} & Omit<ComponentPropsWithoutRef<C>, keyof Props | "as">;

// Example: a Box component with its own props plus the target element's props
type BoxOwnProps = {
  padding?: "sm" | "md" | "lg";
};

function Box<C extends ElementType = "div">({
  as,
  padding = "md",
  className,
  ...rest
}: PolymorphicProps<C, BoxOwnProps>) {
  const Component = as || "div";
  return <Component className={`box box-${padding} ${className ?? ""}`} {...rest} />;
}

// TypeScript now knows the exact prop shape per "as" value:
// <Box as="a" href="/docs">   -> href is required/typed for anchors
// <Box as="button" onClick={...} />  -> onClick typed as button event

4. Props inheritance with ComponentPropsWithoutRef

For a polymorphic component to truly work as a drop in replacement for the native element, all standard props of the target element must be automatically available without listing them manually. ComponentPropsWithoutRef<C> extracts exactly these props from the generic parameter C, whether it is a native element or a custom component. The WithoutRef in the name matters, because ref has to be handled separately for polymorphic components.

The trick when combining your own props with inherited props lies in the Omit: your own props, such as padding in the previous example, should take precedence over native props with the same name if there is overlap. Without this Omit, TypeScript would form a union type on name collisions, which often does not produce the desired behavior for the polymorphic component in practice. This order, defining your own props first and then adding native props except for your own names, is the most reliable approach.

5. Ref forwarding for polymorphic components

Ref forwarding is the part most often implemented incorrectly or incompletely for polymorphic components. Since the actual target element is only known at runtime, the ref type must also be generic: an as="a" call needs an HTMLAnchorElement ref, an as="button" call needs an HTMLButtonElement ref. React's helper type ElementRef<C> solves exactly this by deriving the correct ref type from the generic element parameter.

Since React 19, ref is a plain prop and no longer strictly needs to be passed through forwardRef, which noticeably simplifies the code for polymorphic components. In older codebases running React 18 or earlier, forwardRef remains necessary, combined with an explicit generic type parameter, because forwardRef itself only supports generic components in a limited way and you often need a small type assertion to pass both generics through cleanly.


// Button.tsx — polymorphic component with correctly typed ref (React 19)
import type { ElementType, ElementRef, ComponentPropsWithoutRef } from "react";

type ButtonOwnProps = {
  variant?: "primary" | "ghost";
};

type ButtonProps<C extends ElementType> = ButtonOwnProps & {
  as?: C;
  ref?: ElementRef<C> | null;
} & Omit<ComponentPropsWithoutRef<C>, keyof ButtonOwnProps | "as" | "ref">;

function Button<C extends ElementType = "button">({
  as,
  variant = "primary",
  ref,
  className,
  ...rest
}: ButtonProps<C>) {
  const Component = as || "button";
  return (
    <Component ref={ref} className={`btn btn-${variant} ${className ?? ""}`} {...rest} />
  );
}

// Usage — ref type adapts automatically to the "as" value
// const anchorRef = useRef<HTMLAnchorElement>(null);
// <Button as="a" href="/docs" ref={anchorRef}>Docs</Button>

6. Building your own polymorphic component factory

Once a design system needs more than two or three polymorphic components, a shared factory function that defines the generic type and ref forwarding in a single place pays off. Instead of writing every component individually with the full generic signature, a helper function such as createPolymorphicComponent encapsulates the boilerplate and returns a ready made, typed component. This significantly reduces repetition and ensures that all polymorphic building blocks of the same design system behave identically.

Such a factory typically takes a render function that knows the default element plus its own props, and returns a component that is already fully generic typed for arbitrary as values. This approach pays off especially in libraries with ten or more polymorphic components, where manually repeating the generic signature would otherwise become a significant source of errors during refactoring.

7. Accessibility across changing elements

An often overlooked aspect of polymorphic components is that changing the root element automatically changes the semantics for screen readers and keyboard users. A <button> is focusable by definition and can be triggered with enter or space, while a <div> is not. If a button component is rendered with as="div" without adding extra ARIA attributes and keyboard handlers, you end up with an element that visually looks like a button but behaves like a meaningless container for screen reader users.

The most reliable safeguard is to restrict the as prop on interactive polymorphic components to a sensible subset, for example only "button", "a", and explicitly supported link components, instead of allowing arbitrary elements. In addition, the component should automatically add role="button", tabIndex={0}, and a keyboard handler for enter and space for target elements that are not natively interactive, so accessibility is preserved regardless of the chosen element.

8. Pitfalls and performance

A common mistake with polymorphic components is recomputing the target component on every render without paying attention to reference stability. Since Component is used as a variable in the JSX expression, an unstable reference between renders causes React to fully remount the entire element, including all child components, instead of just updating it. This destroys local state in child components and can cause visible bugs in forms or animations.

A second pitfall concerns complex generic signatures: very deeply nested polymorphic components with several generic layers can strain TypeScript's type inference so much that editor autocomplete becomes noticeably slower. In practice, a single generic parameter for as is usually enough, additional generics for other aspects of the component should only be introduced when really necessary, not out of pure upfront design.

9. The as prop compared to alternatives

The as prop is not the only solution for polymorphic components. The following table compares the common approaches.

Approach Type safety Flexibility Complexity
as prop with ElementType High, with effort Any element at runtime Medium to high
asChild / Slot pattern High One single child replaces root Medium
Separate variant components Very high Low, fixed selection Low per component, high overall
any / string type for as None Full Very low

In practice, the as prop with ElementType is the best compromise for generic design system building blocks such as Text, Box, or Button, while the Slot pattern is better suited when exactly one child element should replace the root, for example a link that should visually look like a button. Separate variant components only pay off when the number of possible elements is small and stable and maximum type safety without generic complexity is desired.

Mironsoft

React component design and TypeScript

Need type safe polymorphic components for your design system?

We design and implement polymorphic building blocks with clean TypeScript typing, correct ref forwarding, and accessible behavior regardless of the chosen element.

Type architecture

ElementType and generic signatures for your component API

Component factory

Reusable boilerplate for all your polymorphic building blocks

Accessibility check

ARIA and keyboard control regardless of the rendered element

10. Summary

Polymorphic components with the as prop solve a central design system problem: the same component should be able to render different HTML elements without losing type safety or accessibility. ElementType generically binds the chosen element to the component, ComponentPropsWithoutRef automatically inherits all matching standard props, and ElementRef ensures a correctly typed ref no matter which element is active.

Whoever uses polymorphic components in a library with many building blocks benefits from a shared factory function that bundles the generic boilerplate in one place. What remains important in every case is restricting the as prop to sensible values for interactive components and adding ARIA attributes plus keyboard handlers for target elements that are not natively interactive, so flexibility does not come at the cost of accessibility.

Polymorphic Components with the as Prop — The Essentials

Core principle

An as prop determines the rendered element, the visual logic stays identical across all variants.

Typing

ElementType plus ComponentPropsWithoutRef automatically inherits the right props per element.

Ref forwarding

ElementRef derives the correct ref type. Since React 19, ref is a plain prop.

Accessibility

Restrict as values, add ARIA and keyboard handlers for non natively interactive elements.

11. FAQ: Polymorphic Components with the as Prop

1What is a polymorphic component?
Renders a different element based on the as prop, with identical API and visual behavior.
2What is ElementType for?
Allows native tags and component references as the as type, foundation for correct props inference.
3What does ComponentPropsWithoutRef do?
Extracts an element's standard props without ref, since ref is typed separately via ElementRef.
4Why is ref forwarding harder here?
Ref type depends on the target element. ElementRef derives it automatically from the generic parameter.
5Do I still need forwardRef?
Not strictly since React 19, but still required in React 18 and earlier.
6How is accessibility preserved?
Restrict as values, add ARIA and keyboard handlers for non natively interactive elements.
7Difference to the Slot pattern?
Slot replaces root with a passed child element, as prop selects the element via value.
8Why does state break on re render?
Unstable component reference makes React treat it as a new element type and remount children.
9Worth a factory function?
From roughly three building blocks, a shared factory reduces repetition significantly.
10Can I restrict as?
Yes, with a union of concrete allowed elements instead of an open ElementType.