In enterprise design systems, a component's visual styling should never be rigidly bound to a single HTML tag. For instance, an interactive <Button> element may visually look like a high-emphasis action button, but depending on page semantics and accessibility (a11y) constraints, it must render as a native <button>, a standard hyperlink <a href="...">, or a framework router link <Link href="...">.
Modern polymorphism in React 19 tackles dynamic tag/component replacement via the as prop while retaining strict autocomplete and HTML attribute validation:
Without polymorphism, engineering teams inevitably resort to one of three architectural antipatterns:
<Button>, <LinkButton>, <ExternalLinkButton>, and <DivButton>. This results in codebase bloat, styling divergence, and duplicated state logic (hover, focus, disabled, loading).as?: any): Resorting to any or loose, untyped unions. When this happens, TypeScript allows passing href to a native <button> (where the browser ignores it) or type="submit" to an <a> tag, breeding subtle bugs and accessibility failures.<button><a href="...">...</a></button>), which is an egregious violation of the HTML5 specification (nested interactive controls) and completely disrupts screen readers.// ❌ ANTIPATTERN: Untyped polymorphism
interface BadButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
as?: any;
href?: string; // Permitted on every call, even when rendering <button>!
}
// ✅ GOAL OF STRICT POLYMORPHISM:
<Button as="button" type="submit">Submit</Button>; // Valid
<Button as="a" href="/dashboard">To Dashboard</Button>; // Valid: href is allowed
// @ts-expect-error — Property 'href' does not exist on type Button when as="button"
<Button as="button" href="/dashboard">Error</Button>;In TypeScript, polymorphism relies on discriminant type inference via Generics. Rather than creating static unions of dozens of HTML interfaces, we define the component as a generic function parameterized by the target element E extends React.ElementType.
The TypeScript compiler resolves polymorphic component props in three consecutive phases:
E from the provided as prop (falling back to 'button').E using React.ComponentPropsWithoutRef<E>.Omit, merging the sanitized native attributes with the component's custom props.Define the component's unique interface (variant, size, isLoading, color) independent of native HTML attributes.
Declare the component with generic parameter <E extends React.ElementType = 'button'> to dynamically capture the tag type.
Exclude overlapping keys between custom props, the 'as' prop, and React.ComponentPropsWithoutRef<E>.
Bind React.ComponentPropsWithRef<E>['ref'] and apply defensive runtime defaults (e.g. type='button').
Why must PolymorphicComponentProps use Omit<React.ComponentPropsWithoutRef<E>, PropsToOmit<E, Props>> instead of a simple type intersection (&)?
Components like <Typography as="h1" variant="display"> or <Typography as="p" variant="body">, where visual styling tokens are divorced from semantic DOM headings. This allows maintaining strict SEO and a11y heading hierarchies regardless of visual font scaling.
Writing <MenuItem as={Link} href="/settings"> avoids duplicating styling and markup between interactive buttons and client-side router links. TypeScript automatically enforces the required href prop on Link.
Transforming a <Card as="article"> into a navigable link without rendering illegal HTML. Nesting block-level interactive controls inside an <a> tag violates the HTML5 spec, making polymorphism crucial for container nodes.
Type resolution for polymorphic components in TypeScript follows a deterministic pipeline:
Determines the value of generic parameter E (defaulting to 'button' or the provided tag/component from the 'as' prop).
Computes keys belonging to custom Props and the 'as' prop to remove them from native element E attributes.
Combines the sanitized native HTML attributes with custom props (variant, size, isLoading).
Resolves the exact ref type (HTMLButtonElement vs HTMLAnchorElement) without unsafe 'any' casting.
In the modern React ecosystem, two patterns dominate dynamic element semantics: classical polymorphism via the as prop and the compositional Slot (asChild) pattern popularized by Radix UI.
What happens if a developer attempts to pass a ref of type React.RefObject<HTMLButtonElement> to a polymorphic Button with as='a'?
Here is a universal polymorphic type module suitable for any design system component:
import * as React from 'react';
/**
* The `as` prop specifying the target HTML tag or React component to render
*/
export type AsProp<E extends React.ElementType> = {
as?: E;
};
/**
* Keys to exclude from native HTML attributes of element E
* to prevent accidental collision with custom component props.
*/
export type PropsToOmit<E extends React.ElementType, P> = keyof (AsProp<E> & P);
/**
* Core polymorphic prop type for components without explicit ref
*/
export type PolymorphicComponentProps<
E extends React.ElementType,
Props = {}
> = React.PropsWithChildren<Props & AsProp<E>> &
Omit<React.ComponentPropsWithoutRef<E>, PropsToOmit<E, Props>>;
/**
* Dynamically resolves the exact Ref type for element E
*/
export type PolymorphicRef<E extends React.ElementType> =
React.ComponentPropsWithRef<E>['ref'];
/**
* Full polymorphic prop type including a strictly typed ref
*/
export type PolymorphicComponentPropsWithRef<
E extends React.ElementType,
Props = {}
> = PolymorphicComponentProps<E, Props> & {
ref?: PolymorphicRef<E>;
};In React 19, ref is a standard component prop and no longer requires the legacy React.forwardRef wrapper. This enables clean, idiomatic generic functions:
import * as React from 'react';
import {
PolymorphicComponentPropsWithRef,
PolymorphicRef,
} from '@/types/polymorphic';
export interface ButtonCustomProps {
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
const variantStyles: Record<NonNullable<ButtonCustomProps['variant']>, string> = {
primary: 'bg-accent-violet text-white hover:bg-accent-violet/90',
secondary: 'bg-bg-surface text-text-primary border border-border hover:bg-bg-surface/80',
ghost: 'bg-transparent text-text-secondary hover:text-text-primary',
destructive: 'bg-red-600 text-white hover:bg-red-700',
};
const sizeStyles: Record<NonNullable<ButtonCustomProps['size']>, string> = {
sm: 'h-8 px-3 text-xs',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
};
/**
* Idiomatic polymorphic button in React 19
*/
export function Button<E extends React.ElementType = 'button'>({
as,
variant = 'primary',
size = 'md',
isLoading = false,
className = '',
children,
ref,
disabled,
...restProps
}: PolymorphicComponentPropsWithRef<E, ButtonCustomProps>) {
const Component = as || 'button';
const baseStyles =
'inline-flex items-center justify-center font-medium rounded-lg transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-violet disabled:opacity-50 disabled:pointer-events-none cursor-pointer select-none';
const combinedClassName = [
baseStyles,
variantStyles[variant],
sizeStyles[size],
className,
]
.filter(Boolean)
.join(' ');
// When rendering a native button, apply type="button" defensively
// to avoid unintended parent form submissions
const defaultProps = Component === 'button' ? { type: 'button' as const } : {};
return (
<Component
ref={ref as React.Ref<Element>}
className={combinedClassName}
disabled={isLoading || disabled}
aria-busy={isLoading || undefined}
{...defaultProps}
{...restProps}
>
{isLoading ? (
<span className="inline-flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{children}
</span>
) : (
children
)}
</Component>
);
}Typography components represent the gold standard for polymorphism in design systems: ensuring strict fidelity to design tokens (color, weight, line-height) while granting complete freedom over semantic HTML tags (h1–h6, p, span, label, code).
import * as React from 'react';
import { PolymorphicComponentPropsWithRef } from '@/types/polymorphic';
export type TypographyVariant =
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'body-lg'
| 'body-md'
| 'body-sm'
| 'caption'
| 'code';
export interface TypographyCustomProps {
variant?: TypographyVariant;
color?: 'primary' | 'secondary' | 'muted' | 'accent';
weight?: 'normal' | 'medium' | 'semibold' | 'bold';
}
const variantStyles: Record<TypographyVariant, string> = {
h1: 'text-4xl sm:text-5xl font-extrabold tracking-tight',
h2: 'text-3xl sm:text-4xl font-bold tracking-tight',
h3: 'text-2xl sm:text-3xl font-bold',
h4: 'text-xl sm:text-2xl font-semibold',
'body-lg': 'text-lg leading-relaxed',
'body-md': 'text-base leading-normal',
'body-sm': 'text-sm leading-normal',
caption: 'text-xs tracking-wide uppercase',
code: 'font-mono text-sm bg-bg-surface/60 px-1.5 py-0.5 rounded border border-border',
};
const colorStyles: Record<NonNullable<TypographyCustomProps['color']>, string> = {
primary: 'text-text-primary',
secondary: 'text-text-secondary',
muted: 'text-text-muted',
accent: 'text-accent-violet',
};
const weightStyles: Record<NonNullable<TypographyCustomProps['weight']>, string> = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
// Default semantic HTML tags mapped to visual variants
const defaultElementMap: Record<TypographyVariant, React.ElementType> = {
h1: 'h1',
h2: 'h2',
h3: 'h3',
h4: 'h4',
'body-lg': 'p',
'body-md': 'p',
'body-sm': 'p',
caption: 'span',
code: 'code',
};
export function Typography<E extends React.ElementType = 'p'>({
as,
variant = 'body-md',
color = 'primary',
weight,
className = '',
children,
ref,
...restProps
}: PolymorphicComponentPropsWithRef<E, TypographyCustomProps>) {
// Use explicit 'as', fallback to variant's semantic default, or base 'p'
const Component = as || defaultElementMap[variant] || 'p';
const classes = [
variantStyles[variant],
colorStyles[color],
weight && weightStyles[weight],
className,
]
.filter(Boolean)
.join(' ');
return (
<Component
ref={ref as React.Ref<Element>}
className={classes}
{...restProps}
>
{children}
</Component>
);
}Attempting to simplify code with as?: any destroys TypeScript's static verification. Developers can pass href to buttons or disabled to links without compiler warnings, leading to runtime failures and accessibility defects.
// ❌ ANTIPATTERN: No type linkage between tag and attributes
interface BadButtonProps extends React.HTMLAttributes<HTMLElement> {
as?: any;
}
// Compiler permits this call even though <button> lacks an href attribute!
<BadButton as="button" href="/dashboard" />
// ✅ RECOMMENDED: Strict generic PolymorphicComponentProps
<Button as="button" href="/dashboard" /> // TS Error: Property 'href' does not exist on type '...'When a custom component declares a prop that shares its name with a native HTML attribute (e.g. custom color union vs legacy HTML color attribute on certain elements), direct intersection Props & React.ComponentPropsWithoutRef<E> results in never or type incompatibility.
// ❌ ANTIPATTERN: Direct intersection without Omit
type BrokenProps<E extends React.ElementType> = {
color?: 'primary' | 'secondary'; // String union
} & React.ComponentPropsWithoutRef<E>; // Some native elements type color as string!
// Result: Conflict between 'primary' | 'secondary' and string
// ✅ RECOMMENDED: Strip native attribute using Omit
type CorrectProps<E extends React.ElementType> = {
color?: 'primary' | 'secondary';
} & Omit<React.ComponentPropsWithoutRef<E>, 'color'>;Forcing ref to React.Ref<HTMLButtonElement> inside a polymorphic component produces hidden runtime bugs if a consumer passes as='a' with an HTMLAnchorElement ref.
// ❌ ANTIPATTERN: Hardcoded button element ref
export function BadComponent<E extends React.ElementType = 'div'>({
ref,
}: {
ref?: React.Ref<HTMLDivElement>; // Conflict if as="button" is supplied!
}) { /* ... */ }
// ✅ RECOMMENDED: Dynamic ref calculation via PolymorphicRef<E>
ref?: PolymorphicRef<E>Under the HTML5 specification, <button> elements without an explicit type attribute default to type="submit". If a polymorphic button renders inside a form without an explicit type, clicks trigger unwanted form submissions. Always provide defensive type="button" defaults when Component === 'button'.
Replacing semantic <button> tags with <div> via as="div" strips keyboard focusability, accessibility tree roles, and Enter/Space key handling. If non-semantic tags must be used, you must manually provide role="button", tabIndex={0}, an onKeyDown handler, and aria-disabled.