DEV Community

Nainik Mehta
Nainik Mehta

Posted on

Polymorphic React Components in TypeScript — 'as' & asChild

Why this matters

I recently reviewed a PR where someone added an href to a div. It rendered fine in the browser — and failed everything else (a11y, semantics, types).

// Bad: href on a div — wrong element for links

Sign up

That kind of bug is exactly why polymorphic components exist: one styled component that can render as different semantic elements (button, a, Link, etc.). But in TypeScript there are three practical tripwires: props, refs and performance. Below I walk through three production-ready patterns I use, with concrete trade-offs and when I pick each.

Quick overview of the three patterns

  • Typed generic as component — great type guarantees, clean API for internal systems. Watch IDE perf.
  • forwardRef-safe polymorphism — a required variant when your component exposes refs (focus, measurements, integrations).
  • asChild / Slot (Radix / shadcn) — excellent composition and IDE perf, weaker static guarantees and some RSC caveats.

1) Typed generic "as" component (strong TypeScript safety)

This uses a generic that maps the ElementType to its props so TypeScript only allows attributes valid for that element. Example usage:

  • — accepts href
  • — accepts disabled
  • — TypeScript will complain

Core types (common utility):

type PolymorphicProps<
  C extends React.ElementType,
  Props = {}
> = Props & { as?: C } & Omit<React.ComponentPropsWithoutRef<C>, keyof Props | 'as'>;

type PolymorphicRef<C extends React.ElementType> = React.ComponentPropsWithRef<C>['ref'];
Enter fullscreen mode Exit fullscreen mode

A simple polymorphic component (no ref):

function Box<C extends React.ElementType = 'div'>(
  { as, children, ...props }: PolymorphicProps<C, { padding?: string }>
) {
  const Component = (as || 'div') as React.ElementType;
  return <Component {...props}>{children}</Component>;
}
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Strong compile-time guarantees: TS refuses invalid attribute combinations.
  • Clean API for internal design systems where you control the surface.

Cons:

  • Complex generics: in very large codebases or files with many polymorphic components, TypeScript's type resolution can slow IDEs (intellisense lag).

I prefer this for small-to-medium internal design systems where type correctness and a simple, documented API matter more than marginal IDE perf.

2) forwardRef-safe polymorphism (when you need refs)

Polymorphism + forwardRef is where many teams trip up. If you wrap a generic component in React.forwardRef without properly typing it, TypeScript can lose the generic context and the as prop stops validating props.

Pattern: create an explicit callable component type and then assign forwardRef to it. That preserves generics and ref typing.

Example:

type BoxOwnProps = { padding?: 'sm' | 'md' | 'lg' };

type BoxComponent = <C extends React.ElementType = 'div'>(
  props: PolymorphicProps<C, BoxOwnProps> & { children?: React.ReactNode }
) => React.ReactElement | null;

const Box: BoxComponent = React.forwardRef(
  <C extends React.ElementType = 'div'>(
    { as, children, padding, ...props }: PolymorphicProps<C, BoxOwnProps>,
    ref?: PolymorphicRef<C>
  ) => {
    const Component = (as || 'div') as React.ElementType;
    return (
      <Component ref={ref as any} {...props}>
        {children}
      </Component>
    );
  }
);
Enter fullscreen mode Exit fullscreen mode

Notes:

  • You may need to cast the ref in some places (ref as any) to satisfy TS specifics — it's not ideal but pragmatic.
  • This is critical when the component must expose refs for focus management, integrations with libraries, or measurement.

3) asChild / Slot (Radix / shadcn pattern)

Radix's Slot (the asChild prop) flips the problem: the consumer passes the element as a child and the library merges its props onto that child via React.cloneElement (or Slot utilities). Example:

import { Slot } from '@radix-ui/react-slot';

function Button({ asChild, children, ...props }: React.ComponentProps<'button'> & { asChild?: boolean }) {
  const Comp = asChild ? Slot : 'button';
  return <Comp {...props}>{children}</Comp>;
}

// Usage
<Button asChild>
  <a href="/signup">Sign up</a>
</Button>
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Avoids heavy generics — better IDE performance in huge codebases.
  • Excellent composition: you can pass Next.js Link, custom components, or raw elements.
  • Handles merging className, event handlers, and composing refs via utilities (Radix uses composeRefs).

Cons / gotchas:

  • Weaker static guarantees: TypeScript can't ensure the child actually accepts all injected props (no compile-time check for href/disabled mismatch).
  • Under the hood it uses cloneElement, which historically caused issues with React Server Components (RSC) and certain optimizations. Radix has released fixes (Slot updates) and there are community workarounds (Children.toArray/unwrap lazy), but be aware if you target RSC/React 19.
  • Runtime errors can happen if the wrapped child doesn't forward refs or doesn't accept required props.

Use asChild for public component libraries and heavy composition scenarios where consumer flexibility and IDE performance are top priorities — but document the expectation that consumer components forward refs and accept the merged props.

Styling: integrate CVA (class-variance-authority)

CVA plays nicely with both approaches. Keep the variant-driven class generation inside your component and merge the final className into whichever element you render.

Example snippet:

import { cva, type VariantProps } from 'class-variance-authority';

const buttonStyles = cva('inline-flex items-center justify-center', {
  variants: {
    intent: { primary: 'bg-blue-600 text-white', ghost: 'bg-transparent' },
    size: { sm: 'px-2 py-1', md: 'px-4 py-2' }
  },
  defaultVariants: { intent: 'primary', size: 'md' }
});

// then use className={cn(buttonStyles({ intent, size }), props.className)} when rendering
Enter fullscreen mode Exit fullscreen mode

CVA keeps the style API consistent whether you render a button, anchor or custom component.

Practical rule and trade-offs

  • Prefer typed generics when you control the environment (internal design systems) and want strong compile-time correctness.
  • Use explicit forwardRef typing for any polymorphic component that exposes refs — it's a common source of hard-to-find breakages.
  • Choose asChild (Slot) for public libraries and complex composition where IDE perf and consumer flexibility matter most — but document RSC caveats and require consumers to forward refs.

Don't over-genericize everything. Only make a component polymorphic when you actually need multiple semantic roots. For many widgets a single semantic element is sufficient and far simpler.

Conclusion

Polymorphic React components in TypeScript are a powerful tool, but each pattern trades type safety, runtime guarantees and IDE performance differently. For internal, type-first systems I reach for typed generics + explicit forwardRef annotations. For public libraries and heavy composition, asChild (Radix Slot) is often the better pragmatic choice — with careful documentation about refs and RSC behavior.

Which pattern does your team use — and what broke for you the hard way?

Top comments (0)