Polymorphic Components (the as prop)
A polymorphic component can render as different elements/components via an as (or component) prop while keeping its own props and inferring the correct DOM props for whatever it renders. <Box as="a" href="…" /> renders an anchor and accepts href; <Box as="button" /> accepts onClick/disabled. The behavior is easy; typing it correctly in TypeScript (with forwardRef) is the hard, senior-level part. Radix’s asChild/Slot is the popular alternative that avoids the typing pain.
What problem does as solve?
Reuse one component’s styling/behavior across different semantic elements without duplicating it. A design-system Button might need to be a real <a> for navigation (so it’s a link, keyboard-and-SEO-correct) but look identical. as lets one component cover both: <Button as="a" href="/x"> vs <Button onClick={…}>. It keeps semantics correct (Semantic HTML and Landmarks) instead of slapping role on a <div>.
How do you type a polymorphic component in TypeScript?
Generic over an ElementType, merging your own props with the rendered element’s props (minus collisions):
import { ElementType, ComponentPropsWithoutRef, ReactNode } from "react";
type BoxProps<E extends ElementType> = {
as?: E;
children?: ReactNode;
} & Omit<ComponentPropsWithoutRef<E>, "as" | "children">;
function Box<E extends ElementType = "div">({ as, ...rest }: BoxProps<E>) {
const Tag = as ?? "div";
return <Tag {...rest} />;
}
// usage — `href` is type-checked because as="a"
<Box as="a" href="/home">Home</Box>;
<Box as="button" onClick={() => {}} />;ComponentPropsWithoutRef<E> pulls the valid props for E; Omit<…, "as"> prevents your props clashing with the element’s. Default the generic (= "div") so <Box> works with no as. See Typing React Components.
How do you add ref forwarding to a polymorphic component?
This is the genuinely fiddly part — forwardRef doesn’t preserve generics well, so the ref type must come from ComponentPropsWithRef<E>["ref"] (or ElementRef<E>), and you cast the wrapped component to keep the generic call signature:
import { ElementType, ComponentPropsWithRef, forwardRef, Ref } from "react";
type Props<E extends ElementType> = { as?: E } & Omit<ComponentPropsWithRef<E>, "as">;
const Box = forwardRef(function Box<E extends ElementType = "div">(
{ as, ...rest }: Props<E>,
ref: ComponentPropsWithRef<E>["ref"]
) {
const Tag = as ?? "div";
return <Tag ref={ref} {...rest} />;
}) as <E extends ElementType = "div">(p: Props<E> & { ref?: Ref<unknown> }) => JSX.Element;Most teams copy a helper type (PolymorphicComponentPropsWithRef) or use a library type rather than rewrite this each time. (React 19 relaxes some of this — ref as a prop without forwardRef.)
as prop vs Radix asChild?
as prop |
asChild (Slot) |
|
|---|---|---|
| Mechanism | component renders the element you name | component merges its props onto your child |
| Typing | complex generics for full safety | simpler — your child is your element |
| Wrapper | none | none |
| Risk | prop collisions, generic ref pain | child must forward ref + spread props |
Both avoid wrapper <div>s. asChild sidesteps the polymorphic-typing problem by letting you supply the concrete element. See Headless UI and Radix.
How do libraries do this? (MUI/Chakra)
MUI uses a component prop (its name for as) plus an sx/styled system; Chakra and styled-system use as. They ship the polymorphic types so consumers get prop inference. Knowing the pattern explains why <Button component={Link} to="/x" /> type-checks to.
Gotchas
- Prop collisions — if your component defines
colorand the element also hascolor,Omitwhich one? Decide and document; unmanaged collisions produce confusing type errors. forwardReferases the generic — without the cast/helper,asloses inference andrefis typed wrong. This is the single most common polymorphic-typing bug.- Runtime vs type safety —
as={SomeComponent}that doesn’t accept the spread props fails at runtime even if loosely typed; keep the constraint tight. - Perf — a deeply polymorphic primitive used thousands of times adds a tiny indirection; negligible in practice, but don’t make everything polymorphic “just in case.”
- Required props of the target — typing can’t always force
hrefwhenas="a"; some setups makehrefoptional. Be aware safety isn’t always total.
Related
Further reading
- “How to build polymorphic React components with TypeScript” (Ohans Emmanuel): https://www.benmvp.com/blog/polymorphic-react-components-typescript/
- Radix
Slot/asChild: https://www.radix-ui.com/primitives/docs/utilities/slot - React 19 —
refas a prop: https://react.dev/blog/2024/12/05/react-19#ref-as-a-prop
Interview angle 3
- “What is a polymorphic component?” - one that renders a different element
via an
asprop while keeping its styles and inferring the right props for that element. It is how aButtonrenders as an anchor for navigation without losing link semantics or gaining a nested-interactive-element bug. - “What makes it hard?” - the types. The component is generic over
ElementType, mergesComponentPropsWithoutRef<E>with its own props minus the collisions, and needs a sensible default for the generic. It is one of the genuinely difficult things to type correctly in React.- Follow-up: “And
forwardRefon top?” -forwardReferases the type parameter, so the generic is lost unless you cast the result to a hand-written signature. React 19’s ref-as-prop removes most of that ceremony for new components.
- Follow-up: “And
- “Is there a simpler way?” - Radix’s
asChildwithSlot, which merges props and behaviour onto whatever concrete child you pass. You give up the single-prop ergonomics and get out of the generic-typing problem entirely, which for most design systems is the better trade.