Skip to content

The render prop

Every component takes render. It replaces the component’s default element with whatever you pass, merging the component’s props, classes, handlers and ref onto it.

<Chip render={<li />}>React</Chip>
<Button render={<motion.button />}>Save</Button>
<Card render={<article />} lift>…</Card>

Chip used to take as?: 'span' | 'button'. A closed union can only ever list the elements its author happened to think of, and the moment a consumer needs something else the component is useless to them. In this project’s own consumer that happened three times — the class names were copied out by hand, and the component was abandoned at those call sites.

render has no such ceiling: any element, any component, including ones that do not exist yet.

PropBehaviour
event handlerschained, most specific first — the render element’s, then yours, then the component’s
classNameconcatenated — both apply
styleshallow-merged
refmerged — both the component and your ref receive the node
everything elselater wins, so you can override aria-label, id, tabIndex

undefined values are skipped, so an absent prop never clobbers one the component set.

Because handlers run before the component’s own, you can stop it from acting:

<Switch.Root onClick={(event) => event.preventComponentHandler()} />

preventDefault() is left alone — it still means only “cancel the browser’s default action”.

State belongs on the component; the render element only decides what gets rendered. The component derives attributes such as disabled, aria-* and data-* from its own props, and the render element’s own props win the merge — so writing one there overrides the markup, never the component’s state.

<Switch.Root disabled render={<motion.button />} /> // correct
<Switch.Root render={<motion.button disabled />} /> // markup only — `data-disabled` and the form input stay enabled

Nothing is checked or warned about: what you pass through render is yours.

The same applies to type on a button: <Chip render={<button type="button" />}>, or it defaults to type="submit".

render exists so styling never costs you semantics. A chip inside a list stays an li; a clickable chip stays a focusable button; a card wrapping navigation stays a labelled nav. If you find yourself copying class names to get the right element, that is the bug render was added to fix.