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
asprop across styled primitives so consumers can swap semantics (buttonvsa) 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 everyE.Radix primitives often expose
asChildrather thanas: 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 usingSlotfrom@radix-ui/react-slotto clone the child. The mental model differs (asChildvsas), 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.