Polymorphic Components in React with TypeScript

ruijadom
@ruijadom
Have you ever built a Button component only to realize you need it to sometimes render as a link? Or created a Text component that needs to be a <p>, <span>, <h1>, or any other element depending on context? Welcome to the world of polymorphic components. One of the most powerful advanced patterns in React.
In this post, I'll explore how to build truly flexible components that can morph into different elements while maintaining full TypeScript type safety and excellent developer experience.
Why This Pattern Matters
This advanced pattern is crucial for modern design systems and has significant benefits on both sides:
For Design System Developers:
- Consistency at Scale: Ensure all components follow the same API patterns, making the system predictable and easier to maintain
- Reduced Component Bloat: Instead of creating
Button,ButtonLink,ButtonDiv, and countless variants, you maintain one intelligent component - Future-Proof Architecture: New use cases don't require new components. The existing ones adapt
- Type Safety Guarantee: TypeScript ensures consumers can't misuse props, catching errors at compile time rather than runtime
For Application Developers (Consumers):
- Flexibility Without Compromise: Use the right HTML element for accessibility and SEO while maintaining design consistency. No need to clone components just to change the underlying tag
- Native Attributes Available: Access all native HTML attributes (like
href,target,disabled) and ARIA attributes (likearia-label,aria-expanded) without the design system having to explicitly expose them - Smaller Bundle Size: Fewer component variants mean less code to ship to users
- Better Developer Experience: One component API to learn instead of many similar ones
- Semantic HTML Made Easy: Write accessible markup without sacrificing your design system's visual language or having to copy-paste styles
In enterprise environments, where design systems serve dozens of teams and applications, this pattern becomes essential. It reduces friction between design system maintainers and consumers, allowing both to move faster without sacrificing quality.
What Are Polymorphic Components?
Polymorphic components (also called "as" components or polymorphic props) are React components that can render as different HTML elements or even other React components, determined by a prop typically called as.
The Problem They Solve
Consider this common scenario:
// Without polymorphism - you need multiple components
<Button>Click me</Button>
<ButtonLink href="/dashboard">Go to Dashboard</ButtonLink>
<IconButton icon={<Icon />}>Submit</IconButton>
// With polymorphism - one flexible component
<Button>Click me</Button>
<Button as="a" href="/dashboard">Go to Dashboard</Button>
<Button as={Link} to="/dashboard">Go to Dashboard</Button>Instead of creating Button, ButtonLink, ButtonAsDiv, etc., you create one intelligent component that adapts to your needs while preserving type safety for the underlying element's props.
Why Use Polymorphic Components?
Reduced Component Sprawl
Instead of maintaining separate Button, ButtonLink, ButtonDiv components, you maintain one component with intelligent props. This means less code, easier maintenance, and a simpler API for consumers.
Type Safety
With proper TypeScript implementation, you get full autocomplete and type checking for the underlying element's props. When you use as="a", TypeScript knows you can use href. When you use as="button", it knows you can use disabled.
Consistent Styling
All variations share the same base styles and behavior. A button rendered as a link looks and feels like your button, but functions as a link.
Better Semantics
You can use the correct HTML element for the job while maintaining consistent appearance. Need a link that looks like a button? Use semantic HTML (<a>) with button styles, improving accessibility and SEO.
Composition Over Duplication
Follow DRY principles. Write your component logic once and let polymorphism handle the variations.
Basic Implementation
Let's start with a simple polymorphic Text component:
import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react'
interface TextOwnProps {
children: ReactNode
color?: 'default' | 'primary' | 'secondary'
size?: 'sm' | 'md' | 'lg'
}
type TextProps<T extends ElementType> = TextOwnProps & {
as?: T
} & ComponentPropsWithoutRef<T>
function Text<T extends ElementType = 'p'>({
as,
children,
color = 'default',
size = 'md',
...props
}: TextProps<T>) {
const Component = as || 'p'
const className = `text text--${color} text--${size}`
return (
<Component className={className} {...props}>
{children}
</Component>
)
}
// Usage with full type safety
function Example() {
return (
<>
{/* Renders as <p> */}
<Text>Default paragraph</Text>
{/* Renders as <h1> - TypeScript knows about h1 props */}
<Text as="h1" id="title">Main Heading</Text>
{/* Renders as <span> */}
<Text as="span" color="primary">Inline text</Text>
{/* Renders as <a> - TypeScript requires href! */}
<Text as="a" href="/about" target="_blank">
Link text
</Text>
</>
)
}How It Works
The key is ComponentPropsWithoutRef<T>, which extracts all valid props for the element type T. When you specify as="a", TypeScript automatically knows that href, target, and other anchor props are available.
Advanced Implementation: The Button Component
Let's build a production-ready polymorphic Button component with proper TypeScript support:
import {
ComponentPropsWithoutRef,
ElementType,
ReactNode,
forwardRef,
Ref
} from 'react'
// Our component's own props
interface ButtonOwnProps {
children: ReactNode
variant?: 'primary' | 'secondary' | 'outline' | 'ghost'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
leftIcon?: ReactNode
rightIcon?: ReactNode
}
// Combine our props with the polymorphic element's props
type ButtonProps<T extends ElementType> = ButtonOwnProps &
Omit<ComponentPropsWithoutRef<T>, keyof ButtonOwnProps> & {
as?: T
}
// Helper type for the ref
type ButtonComponent = <T extends ElementType = 'button'>(
props: ButtonProps<T> & { ref?: Ref<any> }
) => ReactNode
const Button: ButtonComponent = forwardRef(
<T extends ElementType = 'button'>(
{
as,
children,
variant = 'primary',
size = 'md',
isLoading = false,
leftIcon,
rightIcon,
className = '',
...props
}: ButtonProps<T>,
ref: Ref<any>
) => {
const Component = as || 'button'
const classes = [
'btn',
`btn--${variant}`,
`btn--${size}`,
isLoading && 'btn--loading',
className
].filter(Boolean).join(' ')
return (
<Component
ref={ref}
className={classes}
disabled={isLoading}
{...props}
>
{leftIcon && <span className="btn__icon-left">{leftIcon}</span>}
{isLoading ? <span>Loading...</span> : children}
{rightIcon && <span className="btn__icon-right">{rightIcon}</span>}
</Component>
)
}
)
Button.displayName = 'Button'
export default ButtonUsage Examples
import { Link } from 'react-router-dom'
import { ArrowRight, Download } from './icons'
function ButtonExamples() {
return (
<div>
{/* Regular button */}
<Button onClick={() => console.log('clicked')}>
Click Me
</Button>
{/* Button as a link */}
<Button as="a" href="/download" download>
Download
</Button>
{/* Button with React Router Link */}
<Button as={Link} to="/dashboard" variant="outline">
Go to Dashboard
</Button>
{/* Button with icons */}
<Button
variant="primary"
rightIcon={<ArrowRight />}
onClick={handleSubmit}
>
Continue
</Button>
{/* Loading state */}
<Button isLoading variant="secondary">
Submitting...
</Button>
{/* Disabled button (TypeScript knows this prop exists) */}
<Button disabled>
Cannot Click
</Button>
{/* As a div (for custom click handling) */}
<Button as="div" role="button" tabIndex={0}>
Custom Clickable
</Button>
</div>
)
}Interactive Example
See polymorphic components in action with this interactive example:
Polymorphic Components Demo
Open in CodeSandbox ↗Real-World Example: Building a Design System
Polymorphic components shine in design systems. Let's build a simplified Box component:
import { ComponentPropsWithoutRef, ElementType } from 'react'
import { cn } from '@/lib/utils' // Utility for merging classNames
interface BoxOwnProps {
children?: React.ReactNode
className?: string
}
type BoxProps<T extends ElementType> = BoxOwnProps & {
as?: T
} & Omit<ComponentPropsWithoutRef<T>, keyof BoxOwnProps>
function Box<T extends ElementType = 'div'>({
as,
children,
className,
...props
}: BoxProps<T>) {
const Component = as || 'div'
return (
<Component className={className} {...props}>
{children}
</Component>
)
}
// Build other components on top of Box
function Card<T extends ElementType = 'div'>({
className,
...props
}: BoxProps<T>) {
return (
<Box
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
)
}
function Container<T extends ElementType = 'div'>({
className,
...props
}: BoxProps<T>) {
return (
<Box
className={cn('mx-auto w-full max-w-7xl px-4', className)}
{...props}
/>
)
}
// Usage
function Layout() {
return (
<Container as="main">
<Box as="header" className="bg-muted py-8">
<Box as="h1" className="text-4xl font-bold">Welcome</Box>
</Box>
<Card as="article" className="p-6">
<Box as="h2" className="text-2xl font-semibold">Article Title</Box>
<Box as="p" className="text-muted-foreground">
Article content here...
</Box>
</Card>
</Container>
)
}Handling Refs with Polymorphic Components
Refs require special handling in polymorphic components:
import { forwardRef, ElementType, ComponentPropsWithoutRef, Ref } from 'react'
interface InputOwnProps {
label?: string
error?: string
}
type InputProps<T extends ElementType> = InputOwnProps & {
as?: T
} & ComponentPropsWithoutRef<T>
type InputComponent = <T extends ElementType = 'input'>(
props: InputProps<T> & { ref?: Ref<any> }
) => React.ReactElement | null
const Input: InputComponent = forwardRef(
<T extends ElementType = 'input'>(
{ as, label, error, ...props }: InputProps<T>,
ref: Ref<any>
) => {
const Component = as || 'input'
return (
<div className="input-wrapper">
{label && <label>{label}</label>}
<Component ref={ref} {...props} />
{error && <span className="error">{error}</span>}
</div>
)
}
)
Input.displayName = 'Input'
// Usage with ref
function Form() {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
}, [])
return (
<Input
ref={inputRef}
label="Email"
type="email"
placeholder="you@example.com"
/>
)
}Common Patterns and Best Practices
1. Set Sensible Defaults
Always provide a default element type:
// ✅ Good - has default
function Text<T extends ElementType = 'p'>({ as, ...props }: TextProps<T>) {
const Component = as || 'p'
// ...
}
// ❌ Bad - no default, requires 'as' every time
function Text<T extends ElementType>({ as, ...props }: TextProps<T>) {
const Component = as // what if undefined?
// ...
}2. Avoid Prop Conflicts
Use Omit to prevent prop name collisions:
type ButtonProps<T extends ElementType> = ButtonOwnProps &
Omit<ComponentPropsWithoutRef<T>, keyof ButtonOwnProps> & {
as?: T
}This ensures your custom props (like variant) don't conflict with native element props.
3. Provide Type Utilities
Make it easier for consumers to extend your components:
import { ComponentPropsWithoutRef, ElementType } from 'react'
// Export utility type for users who want to extend your component
export type PolymorphicComponentProps<
T extends ElementType,
Props = {}
> = Props &
Omit<ComponentPropsWithoutRef<T>, keyof Props> & {
as?: T
}
// Now users can easily create their own polymorphic components
interface MyButtonProps {
isAwesome?: boolean
}
type MyButtonComponentProps<T extends ElementType> =
PolymorphicComponentProps<T, MyButtonProps>4. Document Supported Elements
Not all elements make sense for all components:
/**
* Button component that can render as different elements.
*
* @example
* ```tsx
* <Button>Regular button</Button>
* <Button as="a" href="/link">Button link</Button>
* <Button as={Link} to="/route">Router link</Button>
* ```
*
* Supported elements:
* - button (default)
* - a
* - div (with proper ARIA attributes)
* - Any React component that accepts onClick
*/When to Use Polymorphic Components
Use polymorphic components when:
Building design system primitives
Components like Box, Text, Button, Flex, Stack that form the foundation of your UI need maximum flexibility.
You need semantic HTML flexibility
When the same visual component needs different HTML semantics (button vs link, div vs section, span vs p).
Creating wrapper/layout components
Components that primarily handle styling or layout but shouldn't dictate the underlying HTML structure.
Building library components
When creating reusable components for multiple projects or teams where you can't predict all use cases.
Avoid polymorphic components when:
The component has complex behavior
If your component has intricate internal logic tied to specific element types, polymorphism can make it brittle. A Form component should probably stay a <form>.
You need strict type safety
While polymorphic components are type-safe, complex polymorphic types can become hard to debug. If you need ironclad guarantees about props, simpler types might be better.
The API becomes confusing
If users frequently misuse the as prop or choose inappropriate elements, it might be better to create separate components with clear purposes.
Common Pitfalls and Solutions
Pitfall 1: TypeScript Errors with Complex Props
Problem: TypeScript struggles with complex polymorphic types.
Solution: Simplify your type definitions and use helper types:
// Instead of inline types, extract them
type PolymorphicProps<T extends ElementType, OwnProps> =
OwnProps &
Omit<ComponentPropsWithoutRef<T>, keyof OwnProps> &
{ as?: T }
// Then use the helper
interface ButtonOwnProps {
variant?: 'primary' | 'secondary'
}
type ButtonProps<T extends ElementType> = PolymorphicProps<T, ButtonOwnProps>Pitfall 2: Losing Ref Types
Problem: ref type becomes any.
Solution: Use proper ref typing with ElementRef:
import { ElementRef, ElementType, forwardRef } from 'react'
type ButtonComponent = <T extends ElementType = 'button'>(
props: ButtonProps<T> & {
ref?: Ref<ElementRef<T>> // ✅ Properly typed ref
}
) => ReactNodePitfall 3: Default Props Not Working
Problem: Default values for as don't infer correctly.
Solution: Always fallback in the component body:
function Button<T extends ElementType = 'button'>({
as,
...props
}: ButtonProps<T>) {
const Component = as || 'button' // ✅ Explicit fallback
return <Component {...props} />
}Advanced: Conditional Props Based on Element
Sometimes you want different props based on the as value:
import { ComponentPropsWithoutRef, ElementType } from 'react'
interface BaseProps {
children: React.ReactNode
variant?: 'primary' | 'secondary'
}
// Additional props only when rendered as a link
interface LinkProps {
external?: boolean
newTab?: boolean
}
type ButtonProps<T extends ElementType> = BaseProps & {
as?: T
} & ComponentPropsWithoutRef<T> &
(T extends 'a' ? LinkProps : {}) // Conditional props!
function Button<T extends ElementType = 'button'>({
as,
variant = 'primary',
children,
external,
newTab,
...props
}: ButtonProps<T>) {
const Component = as || 'button'
// When rendered as 'a', apply link-specific logic
if (Component === 'a' && external) {
return (
<Component
rel="noopener noreferrer"
target={newTab ? '_blank' : undefined}
{...props}
>
{children} ↗
</Component>
)
}
return <Component {...props}>{children}</Component>
}
// Usage
<Button as="a" href="/docs" external newTab>
Documentation
</Button>Testing Polymorphic Components
Testing polymorphic components is straightforward
import { render, screen } from '@testing-library/react'
import Button from './Button'
describe('Button', () => {
it('renders as button by default', () => {
render(<Button>Click</Button>)
const button = screen.getByRole('button')
expect(button.tagName).toBe('BUTTON')
})
it('renders as link when as="a"', () => {
render(<Button as="a" href="/test">Link</Button>)
const link = screen.getByRole('link')
expect(link.tagName).toBe('A')
expect(link).toHaveAttribute('href', '/test')
})
it('renders with custom component', () => {
const CustomLink = ({ children, ...props }: any) => (
<a {...props}>{children}</a>
)
render(<Button as={CustomLink} to="/custom">Custom</Button>)
expect(screen.getByText('Custom')).toBeInTheDocument()
})
it('applies variant styles correctly', () => {
render(<Button variant="secondary">Secondary</Button>)
expect(screen.getByRole('button')).toHaveClass('btn--secondary')
})
})Conclusion
Polymorphic components are a powerful pattern for building flexible, reusable React components with excellent TypeScript support. They're essential for design systems and component libraries, enabling you to write less code while providing more functionality.
Here are my key takeaways:
- Use
asprop to allow element type customization - Leverage TypeScript for full type safety with
ComponentPropsWithoutRef - Set sensible defaults so the component works out of the box
- Handle refs properly with
forwardRefand correct typing - Document expected usage to prevent misuse
- Don't overuse — simple components don't need polymorphism
I recommend starting with simple polymorphic components like Text and Box, then gradually adopting the pattern for more complex components as you see the benefits. Your component consumers will thank you for the flexibility, and TypeScript will keep everything safe.
Resources: