Frontend / Component libraries / polymorphic_components.md

Polymorphic Components (the as prop)

Updated 4 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

Polymorphic Components (the as prop)

TL;DR

A polymorphic component can render as different elements/components via an as (or component) prop while keeping its own props and inferring the correct DOM props for whatever it renders. <Box as="a" href="…" /> renders an anchor and accepts href; <Box as="button" /> accepts onClick/disabled. The behavior is easy; typing it correctly in TypeScript (with forwardRef) is the hard, senior-level part. Radix’s asChild/Slot is the popular alternative that avoids the typing pain.

In depth

What problem does as solve?

Reuse one component’s styling/behavior across different semantic elements without duplicating it. A design-system Button might need to be a real <a> for navigation (so it’s a link, keyboard-and-SEO-correct) but look identical. as lets one component cover both: <Button as="a" href="/x"> vs <Button onClick={…}>. It keeps semantics correct (Semantic HTML and Landmarks) instead of slapping role on a <div>.

How do you type a polymorphic component in TypeScript?

Generic over an ElementType, merging your own props with the rendered element’s props (minus collisions):

tsx
import { ElementType, ComponentPropsWithoutRef, ReactNode } from "react";

type BoxProps<E extends ElementType> = {
  as?: E;
  children?: ReactNode;
} & Omit<ComponentPropsWithoutRef<E>, "as" | "children">;

function Box<E extends ElementType = "div">({ as, ...rest }: BoxProps<E>) {
  const Tag = as ?? "div";
  return <Tag {...rest} />;
}

// usage — `href` is type-checked because as="a"
<Box as="a" href="/home">Home</Box>;
<Box as="button" onClick={() => {}} />;

ComponentPropsWithoutRef<E> pulls the valid props for E; Omit<…, "as"> prevents your props clashing with the element’s. Default the generic (= "div") so <Box> works with no as. See Typing React Components.

How do you add ref forwarding to a polymorphic component?

This is the genuinely fiddly part — forwardRef doesn’t preserve generics well, so the ref type must come from ComponentPropsWithRef<E>["ref"] (or ElementRef<E>), and you cast the wrapped component to keep the generic call signature:

tsx
import { ElementType, ComponentPropsWithRef, forwardRef, Ref } from "react";

type Props<E extends ElementType> = { as?: E } & Omit<ComponentPropsWithRef<E>, "as">;

const Box = forwardRef(function Box<E extends ElementType = "div">(
  { as, ...rest }: Props<E>,
  ref: ComponentPropsWithRef<E>["ref"]
) {
  const Tag = as ?? "div";
  return <Tag ref={ref} {...rest} />;
}) as <E extends ElementType = "div">(p: Props<E> & { ref?: Ref<unknown> }) => JSX.Element;

Most teams copy a helper type (PolymorphicComponentPropsWithRef) or use a library type rather than rewrite this each time. (React 19 relaxes some of this — ref as a prop without forwardRef.)

as prop vs Radix asChild?

as prop asChild (Slot)
Mechanism component renders the element you name component merges its props onto your child
Typing complex generics for full safety simpler — your child is your element
Wrapper none none
Risk prop collisions, generic ref pain child must forward ref + spread props

Both avoid wrapper <div>s. asChild sidesteps the polymorphic-typing problem by letting you supply the concrete element. See Headless UI and Radix.

How do libraries do this? (MUI/Chakra)

MUI uses a component prop (its name for as) plus an sx/styled system; Chakra and styled-system use as. They ship the polymorphic types so consumers get prop inference. Knowing the pattern explains why <Button component={Link} to="/x" /> type-checks to.

Gotchas / edge cases

  • Prop collisions — if your component defines color and the element also has color, Omit which one? Decide and document; unmanaged collisions produce confusing type errors.
  • forwardRef erases the generic — without the cast/helper, as loses inference and ref is typed wrong. This is the single most common polymorphic-typing bug.
  • Runtime vs type safetyas={SomeComponent} that doesn’t accept the spread props fails at runtime even if loosely typed; keep the constraint tight.
  • Perf — a deeply polymorphic primitive used thousands of times adds a tiny indirection; negligible in practice, but don’t make everything polymorphic “just in case.”
  • Required props of the target — typing can’t always force href when as="a"; some setups make href optional. Be aware safety isn’t always total.

What a senior is expected to say 4

  • as lets one styled component render different semantic elements with correct prop inference — great for Button-as-link without breaking semantics.”
  • “The hard part is the TS: generic over ElementType, merge ComponentPropsWithoutRef<E> minus collisions, default the generic. Adding forwardRef erases the generic unless you cast or use a helper type.”
  • “Radix asChild/Slot is the simpler alternative — merge onto a concrete child instead of a polymorphic prop.”
  • “React 19’s ref-as-prop reduces the forwardRef ceremony.”

Cross-references

Further reading