Polymorphic Components and the as Prop

Design systems rarely want fifty nearly identical components that differ only by the underlying DOM element. You need one visually consistent primitive—spacing, typography, focus rings—that can render as a button, an a, a Next.js Link, or a headless primitive from Radix. The as prop (or an equivalent component prop) is the standard ergonomic answer.

The hard part is typing it. If as is typed as string, you throw away attribute safety: href on a div, onClick without proper button semantics, or missing required anchor props. If you type everything as any, you lose the very reason you chose TypeScript. The pattern that works in production is polymorphic typing with React.ElementType and ComponentPropsWithoutRef: the element type is a generic parameter, and additional props are whatever that element expects—minus the ones your wrapper owns.

A minimal type-safe Box

type PolymorphicProps<E extends React.ElementType> = {
  as?: E
  children: React.ReactNode
} & Omit<React.ComponentPropsWithoutRef<E>, "as" | "children">

function Box<E extends React.ElementType = "div">({
  as,
  children,
  ...props
}: PolymorphicProps<E>) {
  const Component = (as ?? "div") as React.ElementType
  return <Component {...props}>{children}</Component>
}

Call sites now get element-accurate attributes. That precision is the bug fix: you no longer ship an anchor without href because the type surface matches MDN’s expectations for the chosen element (modulo the exact strictness of your installed @types/react version—spot-check critical paths in your editor).

declare function handleClick(): void

;<Box as="button" type="button" onClick={handleClick}>
  Click me
</Box>

;<Box as="a" href="/home">
  Link
</Box>

;<Box as="section" aria-label="main">
  Content
</Box>

For as="a", TypeScript expects anchor attributes—href is not optional in the same way as for a div if your typings follow the DOM closely (project settings and @types/react versions can nuance href; always verify against your installed React types). For as="button", onClick is typed as a DOM handler, not an ad-hoc function with no event shape.

The Omit<..., "as" | "children"> line matters: your component reserves those names. Without Omit, consumers might pass a conflicting as through to the DOM, or you might collide with intrinsic children merging rules.

Default element and generic parameter

E extends React.ElementType = "div" sets the default polymorphic target. That default improves ergonomics for the majority case (<Box> without as) while still allowing specialization when as is provided.

Ref forwarding with ComponentPropsWithRef

Real primitives need forwardRef. The polymorphic pattern extends naturally: you thread the ref type from the chosen element. A common approach is to overload or use a dedicated “with ref” props type that merges ref with ComponentPropsWithRef<E>:

type PolymorphicRefProps<E extends React.ElementType> = {
  as?: E
  children?: React.ReactNode
} & Omit<React.ComponentPropsWithRef<E>, "as">

const BoxWithRef = React.forwardRef(function BoxWithRef<
  E extends React.ElementType = "div",
>(
  { as, children, ...props }: PolymorphicRefProps<E>,
  ref: React.ComponentPropsWithRef<E>["ref"],
) {
  const Component = (as ?? "div") as React.ElementType
  return (
    <Component ref={ref} {...props}>
      {children}
    </Component>
  )
})

Exact signatures vary by codebase—some libraries use conditional types to extract ref more cleanly—but the intent is fixed: refs must match the rendered element. A button ref and an anchor ref are different; polymorphic typing is how you avoid lying to consumers.

How libraries like Chakra UI and Radix use this idea

Production libraries refine the same building blocks you see above:

  • Chakra UI popularized the as prop across styled primitives so consumers can swap semantics (button vs a) without losing theme and style composition. Their internal types combine the polymorphic pattern with style props, which is why their type definitions are large—they are intersecting style-system props with intrinsic element props for every E.

  • Radix primitives often expose asChild rather than as: you pass a single child element that receives the Radix behavior and props. Typing still revolves around merging a known set of Radix props with the child’s props, frequently using Slot from @radix-ui/react-slot to clone the child. The mental model differs (asChild vs as), but the TypeScript problem—merge two prop bags without losing type safety—is the same family.

When you read their source or .d.ts files, you are seeing years of edge-case handling: event handler conflicts, className merging, style merging, and ref forwarding. Your app’s local Box can stay smaller, but the pattern is identical: ElementType + intrinsic props + omissions for reserved keys.

Pitfalls

Excessive polymorphism. Not every div needs to become polymorphic. The type complexity and error messages grow quickly; reserve the pattern for primitives reused across many semantic elements.

Exotic as values. When as is a custom component, its prop types must be compatible with what you spread. Third-party components with weak typings will leak any into your spread. Prefer well-typed targets or wrap them.

Performance of types. Heavy conditional types in polymorphic definitions can slow tsserver. If your design system wraps thousands of files, keep public polymorphic types as simple as possible and push exotic conditional logic inward.

Prop conflicts and merge strategies

Design-system props (padding, tone, sx) can collide with intrinsic names (color, translate). Resolve this by namespacing style props, stripping them before spread, or Omit-ing reserved keys on the public type— mirroring what you do for as and children. TypeScript does not pick merge precedence for you; the runtime implementation and the type definition must agree.

Custom components as as

as={Link} from React Router or Next.js requires compatible props with your spread. Framework links often expect to or href specifically—document supported combinations in examples, and add overloads if one link flavor dominates your app.

Slot-style composition

Radix’s asChild plus Slot merges props onto a child instead of selecting a new intrinsic element. Typing still leans on ComponentPropsWithoutRef for that child. Whether you choose as or asChild, the goal is the same: one implementation, many hosts, with attributes checked against the real element or component.

Summary

The as prop is not a stylistic choice—it is a type-level contract between your design system and the DOM (or router links, or headless primitives). ComponentPropsWithoutRef and ComponentPropsWithRef are the hooks that make that contract honest. Use polymorphic components where semantic flexibility is real; everywhere else, a plain div type is simpler and faster—for both humans and the compiler.