What is Polymorphic component (asChild and render)?

Polymorphic component (asChild and render): A polymorphic component can render as a different element or component than its default, so a Button can be a link while keeping its styles and behavior.

A polymorphic component keeps its styling and behavior but lets the caller change the underlying element. The classic need is a button that must be a link: it should look and act like every other button, but navigate, be crawlable and open in a new tab, which only an anchor can do. The same need appears inside libraries: a dialog's trigger should attach aria-haspopup, aria-expanded and the click handler to your own Button component, not to a second button wrapped around it, which would be invalid HTML.

Three ways to do it

  • The `as` prop: <Button as="a" href="/docs">. Simple to use, but the TypeScript types for "the props of whatever as is" get complicated, and the component must forward everything it does not understand.
  • `asChild` ([Radix UI](/glossary/radix-ui)): <DialogTrigger asChild><Button /></DialogTrigger>. The trigger renders no element of its own; a Slot merges the trigger's props, ref and handlers onto its single child. The child must accept and spread props and forward its ref.
  • The `render` prop ([Base UI](/glossary/base-ui)): <DialogTrigger render={<Button variant="outline" />}>Rename</DialogTrigger>. You pass the element to render, and the primitive merges its props into it. render can also be a function that receives props and state, for cases where the element depends on state.

asChild and render solve the same problem. The render form keeps children where they normally go and offers the function variant, which is why MiniDev adopted it along with Base UI.

How to build one in React

Base UI exposes the mechanism as the useRender hook and mergeProps, which is how MiniDev's own Button and Badge support render. A small version:

tsx
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"

export function Chip({ className, render, ...props }: useRender.ComponentProps<"span">) {
  return useRender({
    defaultTagName: "span",
    props: mergeProps<"span">({ className: "inline-flex rounded-full border px-2 text-xs " + (className ?? "") }, props),
    render,
  })
}

// <Chip>Draft</Chip>                                   renders a span
// <Chip render={<a href="/tags/draft" />}>Draft</Chip>  renders a styled link

mergeProps combines class names, chains event handlers so both the component's and the caller's run, and merges styles, which is the fiddly part of any hand-rolled Slot. In MiniDev the same prop turns a DialogTrigger or PopoverTrigger into a Button, and a Button into a Next.js Link with render={<Link href="/docs" />}.

Details that matter

  • One child only for asChild, and it must be a component that spreads props and forwards its ref, or the behavior silently disappears.
  • Keep the semantics right. A trigger rendered as a div loses keyboard activation; render a button or a link.
  • Type the element, so href is allowed when rendering an anchor and rejected otherwise. useRender.ComponentProps does this for the default tag; for arbitrary elements, prefer render over as.
  • Tell the primitive what it rendered. Base UI's nativeButton flag says whether the element is a real button, so it knows when to add role="button" and key handling itself. MiniDev's Button sets it to false automatically whenever render is used, so a Button rendered as a link gets no spurious warnings.
ButtonuiShadcn compatible React button on Base UI with eight variants, nine sizes including icon sizes, a pressed state and render prop support for links.npx shadcn@latest add @minidev/buttonBadgeuiShadcn style badge in seven variants: default, accent, secondary, destructive, outline, ghost and link. Renders as a span or any element, with icon support.npx shadcn@latest add @minidev/badge