Composition and render
Merging an opsinjs part into your own element with the render prop. How props and refs are combined, and the three ways it goes wrong.
The short version
NOT IMPLEMENTED. This component does not exist in any released version of opsinjs. There is no package to install, no module to import and no props interface to generate code against. Everything on this page is a specification of intended behaviour and may change without notice. Do not write code against it.
This component is not built yet
PlannedRoadmapWhat “planned” means
Every opsinjs part will accept a render prop, inherited from Base UI. It
replaces the element the part would have rendered and keeps everything the part
needs on it: its props, its ref, its event handlers and its data attributes.
// A button that is actually a link.
<Button render={<a href="/results" />}>See your results</Button>This is the same job Radix does with asChild, solved with a prop that takes an
element (or a function) rather than a boolean plus a single child. If you are
arriving from shadcn/ui, asChild is the thing you are looking for; see
Migrating from shadcn/ui.
How it works
The part computes the props it needs and merges them onto the element you
supplied instead of onto one of its own. Those props are accessibility
attributes, event handlers and data-* state. The merge is not a naive spread:
classNameis combined, not replaced, so the part's own classes survive alongside yours.- Event handlers are chained. Your
onClickand the part's both run; the part does not silently drop yours, and yours does not disable the part's behaviour. refis forwarded to your element. Whatever you passed still receives the node.styleis merged, with yours taking precedence.
The function form gives you the computed props directly, for the cases where you need to inspect or reorder them:
<Dialog.Trigger
render={(props, state) => <MyButton {...props} loading={state.pressed} />}
/>Use the element form by default. Reach for the function form only when you need the state.
Do this
-
Use
renderto change the element, not to change the behaviour. ADialog.Triggerrendered as your own button is the intended use; aDialog.Triggerrendered as adivwith your own click handler is a reimplementation with the accessibility removed. -
Spread the props you are given, all of them, first. Then add yours:
render={(props) => <MyButton {...props} className={cn(props.className, "w-full")} />} -
Forward
refin your own wrapper components. A wrapper that swallows the ref breaks focus management, positioning and everyscrollIntoViewin the application, and it does so silently. -
Keep the element type appropriate. If the part expects to be interactive, give it something interactive. Use a
buttonor anarather than aspanwithtabIndex. -
Merge, do not override, the accessibility props. If you set
aria-*on the rendered element after spreading, you are replacing what the part computed, and it computed it for a reason.
Not this
- Do not nest two interactive elements.
<Button render={<a />}>is correct; a<Button>containing an<a>is not, and it produces a control that behaves differently for a mouse, a keyboard and a screen reader. - Do not use
renderto skip a part. Compound components have a part hierarchy for a reason. That reason is usually positioning or ARIA relationships, and collapsing two parts into one element breaks it. The correct nesting for each component is on its page as aCompositionTree. - Do not put the part's props on a wrapper. Passing them to a
divthat contains your button leaves the button without them; the merge target has to be the interactive element itself. - Do not rebuild a part because the styling was hard. The state machine, the ARIA wiring and the keyboard handling are the expensive parts. See Styling for the four hooks that were probably enough.
Gotchas
- A missing
refforward fails silently. Nothing errors; focus simply does not move, or a popup positions at the top-left of the viewport. If a dialog opens in the wrong place, suspect a wrapper that dropped the ref. - Your
onClickruns, and so does theirs. Chaining means callingevent.preventDefault()in yours does not necessarily stop the part's behaviour. Check the part's documentation for what it honours. classNameorder matters fortailwind-merge. Put the incomingprops.classNamefirst so your utilities win the conflict resolution; the other way round and the part's defaults override you.- Function-form
renderre-creates the element on each render. Keep it cheap, and do not define components inside it. That remounts the subtree on every state change and loses focus and input state along with it. renderdoes not make a client component into a server component. If the part is interactive, it is a client boundary regardless of what you render it as. See Server and client components.
Related
- Styling has the four styling hooks to try before you reach for composition.
- TypeScript covers the types involved in a
renderprop and how to type your own wrappers. - Migrating from shadcn/ui has the
asChildtorendertranslation. - Anatomy of a component page is where to find each component's part hierarchy.
Data attributes
The shared state-attribute vocabulary. Where it comes from, which attributes are Base UI's and which are opsinjs's, and why it is a versioned contract.
Forms
Wiring a Field to React Hook Form, TanStack Form or a native form. The validation boundary, server errors, and the accessibility the wiring must not lose.