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 whateverasis" 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; aSlotmerges 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.rendercan 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:
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
divloses keyboard activation; render a button or a link. - Type the element, so
hrefis allowed when rendering an anchor and rejected otherwise.useRender.ComponentPropsdoes this for the default tag; for arbitrary elements, preferrenderoveras. - Tell the primitive what it rendered. Base UI's
nativeButtonflag says whether the element is a real button, so it knows when to addrole="button"and key handling itself. MiniDev's Button sets it to false automatically wheneverrenderis used, so a Button rendered as a link gets no spurious warnings.