Skip to content

Polymorphic Components in React

Type and ship polymorphic as / asChild primitives, flexibility versus accessibility and TypeScript cost.

5 min read
Polymorphic Components
asChild
TypeScript
React
Accessibility
Design System

Typed variants and merge helpers still assume the host element is stable - usually a <button> or <div>. Product UI asks for more: the same visual recipe as a link, a span inside a menu item, or a Radix trigger.

Polymorphism (as, asChild) is how design-system primitives stay thin without forking ButtonLink, ButtonSpan, and ButtonTrigger. The craft is pricing the typing and a11y cost before you expose the escape hatch.

Two common shapes

as prop: consumer picks the element type; you forward props and refs.

tsx
// packages/ui/src/text.tsx, illustrative as-prop import { type ElementType, type ComponentPropsWithoutRef, type ElementRef, forwardRef, } from 'react'; type TextOwnProps<T extends ElementType> = { as?: T; tone?: 'default' | 'muted'; }; type TextProps<T extends ElementType> = TextOwnProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof TextOwnProps<T>>; function TextInner<T extends ElementType = 'p'>( { as, tone = 'default', className, ...props }: TextProps<T>, ref: React.ForwardedRef<ElementRef<T>>, ) { const Comp = as ?? 'p'; return ( <Comp ref={ref as never} data-tone={tone} className={className} {...props} /> ); } export const Text = forwardRef(TextInner) as <T extends ElementType = 'p'>( props: TextProps<T> & { ref?: React.Ref<ElementRef<T>> }, ) => React.ReactElement | null;

asChild (slot): consumer supplies the element; you merge props onto the child (Radix Slot pattern). No second DOM node.

tsx
// illustrative asChild, behavior sketch import { Slot } from '@radix-ui/react-slot'; type ButtonProps = React.ComponentPropsWithoutRef<'button'> & { asChild?: boolean; variant?: 'primary' | 'ghost'; }; export function Button({ asChild, variant = 'primary', ...props }: ButtonProps) { const Comp = asChild ? Slot : 'button'; return ( <Comp data-variant={variant} {...props} /> ); } // usage, styles/behavior merge onto the link <Button asChild variant="ghost" > <a href="/billing">Billing</a> </Button>;

I reach for asChild when the child must be a specific interactive element (router Link, menu item). I reach for as when the primitive is mostly presentational and the element set is small (p | span | label).

Accessibility when the element changes

Changing the tag changes the accessibility tree. That is not a TypeScript problem, it is a product risk.

SwapRisk
buttonaNeed href; Enter/Space expectations differ if you fake a button
buttondivLose native button semantics unless you rebuild role + keyboard
labelspanLose label association; forms break quietly
h2divHeading outline collapses

Decision criteria I use in review:

  1. Does the polymorphic surface allow non-interactive hosts for interactive recipes? If yes, constrain the union or forbid it.
  2. Are we forwarding disabled, aria-*, and type correctly for buttons?
  3. Does asChild merge event handlers without dropping consumer onClick? Slot implementations must compose, not replace.
tsx
// Prefer constrained unions over ElementType for interactive primitives type ButtonAs = 'button' | 'a';

Open ElementType on a Button is how you get <div role="button"> regressions that pass visual QA.

Typing cost versus flexibility

Full polymorphic typing in TypeScript is real work: generic as, ComponentPropsWithoutRef, ref typing, and often a double assertion. Many teams ship a narrow polymorphism instead:

  • Button + ButtonLink (two exports, shared CVA)
  • or asChild only (Radix-style), typed as button props when asChild is false
tsx
// Narrow alternative, shared variants, explicit hosts const buttonVariants = cva(/* ... */); export function Button(props: ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<...>) { return <button className={cn(buttonVariants(props))} {...props} />; } export function ButtonLink(props: ComponentProps<typeof Link> & VariantProps<...>) { return <Link className={cn(buttonVariants(props))} {...props} />; }

I pick the narrow form when the element set is two or three hosts and the generic as API would dominate the file. The rule I keep: polymorphism is a product API, not a TypeScript flex.

Decision criteria before exposing polymorphism

Ship as / asChild when:

  • Real call sites need ≥2 hosts for the same visual recipe this quarter
  • You can constrain the host union (or use Slot) without open ElementType
  • Focus, keyboard, and disabled semantics are defined per host

Prefer fixed elements + thin wrappers when:

  • Only one host exists in production today
  • The team cannot afford the generic typing tax in review
  • Juniors would misuse as="div" for click handlers
tsx
// Domain stays explicit, primitives-to-domain altitude still applies import { Button } from '@acme/ui'; import { Link } from '@tanstack/react-router'; export function BillingNavLink( props: Omit<ComponentProps<typeof Link>, 'className'>, ) { return ( <Button asChild variant="ghost" > <Link {...props} /> </Button> ); }

That wrapper is cheaper than teaching every feature to polymorphic-type a button.

Failure modes

as="div" on clickable chrome. Keyboard and SR users lose affordances.

Prop collisions. as="a" still accepting type="submit". Constrain props per host.

Double wrappers with asChild forgotten. Extra <button><a/></button> invalid HTML and nested interactive controls.

Ignoring July's a11y library post. If you already standardized on Radix / Base UI slots, inventing a third polymorphism layer is duplication.

Ref measuring the wrong node. After asChild, parent layout code that assumed a wrapper div measures the child instead, test refs when you claim the pattern.

Continuity

CVA and tailwind-merge made class recipes typed and merge-safe. Polymorphism asks when the host element is part of the recipe. Composition patterns (render props to hooks) cover when structure beats another prop on the primitive.

Takeaway

Polymorphic as / asChild pays off when it prevents fork components - and loses it when any element can wear a button's clothes without button semantics.