name: composite-components
description: Use when authoring or refactoring Radix-style composite React components in @dxos/react-ui and sibling UI packages — namespaced primitives like Foo.Root / Foo.Trigger / Foo.Content built around forwardRef, Slot, and a tx() theme function.
Composite Components
A "composite" is a namespaced React API like Dialog.Root / Dialog.Trigger / Dialog.Content, modeled after @radix-ui/react-* primitives.
Exemplars
- Pure DXOS composite (no underlying Radix primitive): packages/ui/react-ui/src/components/Panel/Panel.tsx.
- Radix-wrapping composite (each part wraps a
@radix-ui/react-*primitive): packages/ui/react-ui/src/components/Dialog/Dialog.tsx.
Read both before writing a new one.
Two construction styles
Pick one style per part — never mix forms inside a single part.
Style A — slottable() / composable() (pure DXOS)
Use when the part renders a plain DXOS element (a div, span, etc.) and does not wrap a Radix primitive.
const FooContent = slottable<HTMLDivElement>(({ children, asChild, ...props }, forwardedRef) => {
const { className, ...rest } = composableProps(props);
const Comp = asChild ? Slot : Primitive.div;
const { tx } = useThemeContext();
return (
<Comp {...rest} className={tx('foo.content', {}, className)} ref={forwardedRef}>
{children}
</Comp>
);
});
FooContent.displayName = 'Foo.Content';
slottable() (from ../util) auto-forwardRefs, validates asChild children against the COMPOSABLE symbol, and threads composableProps. Use composable() for leaf parts that don't need an asChild branch but should still be valid Slot children.
Style B — forwardRef wrapping a Radix primitive
Use when the part wraps a @radix-ui/react-* primitive that already provides asChild, ref forwarding, and ARIA wiring.
const FooTitle = forwardRef<HTMLHeadingElement, FooTitleProps>(({ classNames, ...props }, forwardedRef) => {
const { tx } = useThemeContext();
return <FooPrimitive.Title {...props} className={tx('foo.title', {}, classNames)} ref={forwardedRef} />;
});
FooTitle.displayName = 'Foo.Title';
For pure pass-through aliases, drop the explicit type — let the alias inherit the primitive's type (preserves forwardRef):
const FooTrigger = FooPrimitive.Trigger;
const FooPortal = FooPrimitive.Portal;
const FooClose = FooPrimitive.Close;
Do not annotate aliases as FunctionComponent<...> — it strips ref support from the type.
Rules
- Prefix internal names:
FooRoot,FooTrigger,FooRootProps. The unprefixedRoot/Triggerform appears only as keys in the final namespace object (export const Foo = { Root: FooRoot, ... }). displayNameis dotted and matches the consumer API:'Foo.Root','Foo.Overlay'— not'FooRoot'or'FooOverlay'. Set it on every part, includingslottable()/composable()ones (the helper does not set it automatically).- Namespace assembly is an object literal. No
Object.assign, noimport * as Foo:export const Foo = { Root: FooRoot, Trigger: FooTrigger, // ... }; - Export every part's Props type:
export type { FooRootProps, FooTriggerProps /* ... */ }; - Section comments delimit each part:
They are cheap structure and make large composite files navigable.// // Root // - Theme tokens: classNames flow through
tx('foo.part', variants, classNames). Forslottable/composableparts, usecomposableProps(props)to reconcile the consumer'sclassNameswith anyclassNameinjected by a parentSlot. Theme tokens live in a siblingFoo.theme.tsregistered withui-theme. - Props convention: extend
SlottableProps<P>(orComposableProps<P>) from@dxos/ui-typesfor native parts; extendThemedClassName<FooPrimitive.SomeProps>for Radix-wrapping parts. UseclassNames(consumer-facing) — never exposeclassNamedirectly. - Context: prefer
createContextfrom@radix-ui/react-contextover React's plaincreateContext(it returns a typed[Provider, useContext]tuple with part-name error messages — better DX). UsecreateContextScopeonly when the composite must nest inside another scoped composite (e.g.,PopoverinsideDropdownMenu). - No
as anydisplayNames. If a part is a plain function component you can't otherwise tag, wrap it incomposable()sodisplayNameis a typed property. - One file per composite family (
Foo.tsx). Don't split parts across files.
Counter-examples to avoid
'DialogOverlay'displayName → should be'Dialog.Overlay'.const DialogClose: FunctionComponent<DialogCloseProps> = DialogPrimitive.Close→ drop the annotation.(CardMenu as any).displayName = 'Card.Menu'→ wrapCardMenuincomposable()instead.- Re-exporting a foreign part inside the namespace (e.g.,
Card.ToolbarIconButton: Toolbar.IconButton) → consumers should importToolbar.IconButtondirectly. - A
Foo.tsxfile that mixesslottable()parts with bareforwardRefparts that render plain divs — pickslottable()for both.