Polymorphic Components: One Component, Many Tags
Learn to build a component whose underlying HTML tag (or component) is controlled by an as prop, correctly merging that tag's native props, typing its ref, and understanding the trade-offs of taking on this pattern.
A polymorphic component renders as different underlying elements depending on a prop — most commonly named as — while keeping one consistent API and one consistent set of styles. A Text component might render a <p>, <span>, or <h2> depending on as; a Button might render a real <button> or, when given as="a", an <a> with an href. Making this fully type-safe — so that as="a" requires href and rejects onClick-only-on-button semantics correctly — is one of the more advanced type-level patterns in React, built entirely from tools covered earlier in this module: ElementType, prop merging, and Omit.
The core mechanism: ElementType and prop merging
The starting point is React.ElementType, the type that covers any valid JSX tag — an intrinsic string like 'button' or 'a', or a component reference. A polymorphic component's props type is generic over which element type is selected, typically named C extends React.ElementType, with a default of some sensible element: type PolymorphicProps<C extends React.ElementType = 'button'> = { as?: C } & ....
The 'merging' part combines your component's own custom props with whatever props the currently-selected C accepts, using React.ComponentPropsWithoutRef<C> (without-ref, since the component will supply its own ref handling). This is why passing as="a" makes href available and type-checked, while passing as="button" (or omitting as and using the default) makes type="submit" and disabled available instead — TypeScript recomputes the merged prop set for whichever C was actually passed.
Internally, the component renders whatever as resolves to using a variable holding the element type: const Component = as || 'button'; return <Component {...rest} />;. This is valid JSX — a capitalized variable holding a string or component reference can be used directly as a tag — and it's the runtime half of what the type system is describing.
Prop conflicts and Omit
When your own custom props share a name with a prop the underlying element already has, the same Omit-then-merge pattern from Native HTML Prop Typing applies: Omit<ComponentPropsWithoutRef<C>, keyof OwnProps> & OwnProps. This removes any native prop name that collides with one of your own custom prop names before merging, so your own prop's type wins the conflict rather than producing an unsatisfiable intersection.
It's worth being deliberate about which props are 'own' props versus which should simply pass through from the underlying element — a polymorphic Button probably wants variant and size as its own props, but should let onClick, disabled, href, and everything else flow through unmodified from whatever C is selected.
Ref typing for polymorphic components
Because the underlying DOM node changes depending on as, the ref type must change too — a ref on as="a" should be typed as HTMLAnchorElement, while a ref on as="button" (the default) should be typed as HTMLButtonElement. This is expressed by computing the ref type from C itself, typically with a helper type like React.ComponentPropsWithRef<C>['ref'] or a small custom PolymorphicRef<C> type, and using React.forwardRef with that computed type.
This is genuinely one of the more intricate type-level constructions in everyday React + TypeScript work — it usually involves a generic forwardRef wrapper function typed as accepting a generic component render function, since plain forwardRef alone isn't naturally generic over the element type in the way a polymorphic component needs. Many teams accept a small amount of as any inside the implementation of the polymorphic component itself (never in its public-facing type) as a pragmatic trade-off, as long as the type consumers see at the call site stays fully accurate.
Accessibility and trade-offs
A polymorphic component changing its rendered tag has real accessibility implications, not just visual ones: a Text as="h2" versus Text as="span" changes the document's heading structure and how screen readers navigate the page, and a Button as="a" needs a valid href to behave like a real link (keyboard-focusable, opens with Enter, shows up correctly in browser history) rather than merely looking like one. The type system can enforce some of this — for instance, requiring href when as="a" — but it can't enforce semantic correctness like 'don't skip from an h2 to an h4,' which stays a code-review and design-system-documentation concern.
The type-level machinery behind a fully generic, ref-safe, prop-merging polymorphic component is genuinely complex, and it's worth asking whether a given component actually needs the full pattern before reaching for it. A component that only ever needs to switch between two or three specific tags can often be typed far more simply with a plain union prop (as?: 'button' | 'a') and two straightforward branches, avoiding the generic ElementType machinery entirely while still covering the real use cases.
The general trade-off to weigh: full polymorphism buys maximum flexibility and reuse (one Button component instead of Button-as-link and Button-as-button variants), at the cost of a harder-to-read prop type, more complex ref handling, and a steeper learning curve for anyone maintaining the component later. Reserve the fully generic pattern for genuinely shared, foundational design-system components where that flexibility earns its keep.
A polymorphic Button with as
Omit<..., keyof Props | 'as'> strips both the own-props keys and as itself out of the native props before merging, avoiding name collisions. The default type parameter C extends React.ElementType = 'button' is what makes as optional and defaults the component's props to a button's.
type AsProp<C extends React.ElementType> = { as?: C };
type PolymorphicProps<C extends React.ElementType, Props = {}> = Props &
AsProp<C> &
Omit<React.ComponentPropsWithoutRef<C>, keyof Props | 'as'>;
type ButtonOwnProps = {
variant?: 'primary' | 'secondary';
};
type ButtonProps<C extends React.ElementType = 'button'> = PolymorphicProps<C, ButtonOwnProps>;
function Button<C extends React.ElementType = 'button'>({
as,
variant = 'primary',
...rest
}: ButtonProps<C>) {
const Component = as || 'button';
return <Component className={`btn btn-${variant}`} {...rest} />;
}Code Examples
For a small, known set of tags, a plain union prop (as?: 'p' | 'span' | 'h1' | 'h2' | 'h3') gives most of the practical benefit of polymorphism — the tag is swappable and type-checked — without the generic ElementType machinery. Expected result: Renders <h2>Section title</h2> and <p>Regular paragraph text.</p> respectively.
With no as, C defaults to 'button' and the component accepts button-specific props like onClick with a native button underneath. With as="a", C becomes 'a', so href is now available and the component renders a real anchor tag while keeping the same variant and className logic. This is exactly the motivating example for the pattern: one Button component's visual styling and API is reused for both an actual button and a link that needs to look identical.
<Button as="a" href="/home">Home</Button>
// Simpler version: a fixed union instead of full ElementType genericity
type TextProps = {
as?: 'p' | 'span' | 'h1' | 'h2' | 'h3';
children: React.ReactNode;
};
function Text({ as: Component = 'p', children }: TextProps) {
return <Component>{children}</Component>;
}
<Text as="h2">Section title</Text>;
<Text>Regular paragraph text.</Text>;// Using the fully generic Button from the syntax block above
<Button variant="primary" onClick={() => console.log('clicked')}>
Save
</Button>;
<Button as="a" href="/home" variant="secondary">
Home
</Button>;
// Compile error: <a> has no `disabled` prop of this kind in this context,
// and omitting href on as="a" usage is a design-time choice to require it
// by also intersecting { href: string } when C is 'a' in a stricter version.How as changes the merged prop type
The generic type parameter C recomputes the merged, native-plus-custom prop set for whichever tag as resolves to.
<Button ... /> as defaults to 'button'
props = ButtonOwnProps + ComponentPropsWithoutRef<'button'>
(onClick, disabled, type, ...)
<Button as="a" ... /> as = 'a'
props = ButtonOwnProps + ComponentPropsWithoutRef<'a'>
(href, target, rel, ...)
Same component definition -> different valid prop set per `as` valueImportant Rules
Reach for a simple union as?: 'a' | 'b' | 'c' prop instead of full ElementType genericity when the component only ever needs to switch between a small, known set of tags.
Always Omit colliding keys (your own prop names, and as itself) from the native props before merging, to avoid unsatisfiable intersections.
Compute the ref type from the current C rather than hardcoding one DOM element type, since the underlying node changes with as.
Treat as changes as accessibility-relevant, not just visual — verify heading structure and interactive semantics (like requiring href for link-like usage) stay correct.
Where this matters
Polymorphic components are a hallmark of mature design systems — a shared Button that can render as <a> for navigation-styled-as-a-button, a Box or Stack layout primitive that can render as any semantic container (div, section, article) while keeping identical spacing props, or a Text component that maps design tokens onto the correct heading level. The pattern lets a design system ship a small number of foundational components instead of a combinatorial explosion of near-duplicate ones.
type ButtonProps<C> = ButtonOwnProps & React.ComponentPropsWithoutRef<C>;
type ButtonProps<C extends React.ElementType = 'button'> = ButtonOwnProps & Omit<React.ComponentPropsWithoutRef<C>, keyof ButtonOwnProps>;
Without constraining C to React.ElementType and Omitting colliding keys, C has no upper bound (so ComponentPropsWithoutRef<C> can't resolve) and any prop name shared between ButtonOwnProps and the native element's props creates a conflicting, unsatisfiable intersection.
<Button as={CustomLink} onClick={fn}>Text</Button> // assuming this always type-checks
Verify CustomLink's own prop type actually includes onClick before relying on it being accepted
When as is set to a custom component rather than an intrinsic tag, the merged props come from that component's own declared prop type — if CustomLink's props don't include onClick, passing it is correctly rejected, which is easy to overlook when testing polymorphism only against intrinsic HTML tags.
[multiple-choice] What is the role of React.ElementType in a polymorphic component's type definition? A. It types a rendered JSX element B. It types any valid JSX tag — an intrinsic string like 'button' or a component reference — which the as prop is constrained to C. It is only used for event handlers D. It replaces ComponentProps entirely
B. It types any valid JSX tag — an intrinsic string like 'button' or a component reference — which the as prop is constrained to — ElementType covers both intrinsic tag strings and component references, making it the correct upper bound for a generic type parameter representing 'whatever the as prop is currently set to.'
[multiple-choice] Why is Omit typically needed when merging a polymorphic component's own props with ComponentPropsWithoutRef<C>? A. To make the merge run faster at build time B. To remove native prop names that collide with the component's own custom prop names, avoiding an unsatisfiable intersection C. Omit is required syntax for all generics D. To remove the children prop specifically
B. To remove native prop names that collide with the component's own custom prop names, avoiding an unsatisfiable intersection — Without Omit, a custom prop name that matches a native prop's name (with a different type) creates a conflicting intersection that collapses to an unusable type — Omit strips the native version out first.
[multiple-choice] According to the trade-offs discussed, when should you avoid the fully generic ElementType-based polymorphic pattern? A. Always — it should never be used B. When the component only needs to switch between a small, known set of tags, where a simple union as?: 'a' | 'b' prop is simpler and sufficient C. Only when using class components D. Whenever the component has more than one prop
B. When the component only needs to switch between a small, known set of tags, where a simple union as?: 'a' | 'b' prop is simpler and sufficient — The full generic pattern adds real complexity (harder-to-read types, intricate ref handling); for a small, fixed set of possible tags, a plain union prop gives most of the practical benefit with far less complexity.
Using the simpler union-prop style (not full ElementType genericity), write a Box component with as?: 'div' | 'section' | 'article' (defaulting to 'div'), a padding?: 'sm' | 'md' | 'lg' own prop, and pass through children plus any native HTMLAttributes<HTMLElement> the caller supplies.
type BoxProps = { as?: 'div' | 'section' | 'article'; padding?: 'sm' | 'md' | 'lg'; children?: React.ReactNode; className?: string; }; function Box({ as: Component = 'div', padding = 'md', className, children }: BoxProps) { return ( <Component className={box box-padding-${padding} ${className ?? ''}}> {children} </Component> ); }
Type the underlying element generically enough to accept HTMLAttributes<HTMLElement> as a base, or keep it simple with just children and className passthrough.