Skip to content

Composition model

A BeeUI compound component is one root that owns state and context, plus named part components that read it — so the parts stay in your markup where you can style and reorder them, while the behavior stays in one place.

Prerequisites: read Ownership model first — this page assumes you already know which decisions belong to BeeUI.

A Select needs a trigger, a value display, a surface, groups, labels and items. There are two ways to expose that. A configuration API takes an options array and renders everything for you: easy at first, then a wall of escape-hatch props the moment a real product needs an icon in an item or a section header in the middle. A composition API hands you the parts.

BeeUI chose composition, and paid for it with a rule: the parts only work inside their root, because that is where their shared context lives.

Select ← owns open state, selected value, dismissal scope
├── SelectTrigger ← reads context: opens, reflects disabled/invalid
│ └── SelectValue ← reads context: renders current selection
└── SelectContent ← portals into the overlay runtime, positions to anchor
└── SelectGroup
├── SelectLabel
└── SelectItem ← reads context: reports selection back to the root

Every compound family in BeeUI has the same shape, only the names change:

Family Root Typical parts
Select Select SelectTrigger, SelectValue, SelectContent, SelectGroup, SelectLabel, SelectItem
Dialog Dialog DialogTrigger, DialogContent, DialogTitle, DialogDescription, DialogFooter, DialogClose
Sheet Sheet SheetTrigger, SheetContent, SheetHandle, SheetTitle, SheetDescription, SheetFooter, SheetClose
Tabs Tabs TabsList, TabsTrigger, TabsContent
Accordion Accordion AccordionItem, AccordionTrigger, AccordionContent
Field Field any control as children; label, description and error are props

A minimal, representative composition:

import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@beemvp/beeui-ui';
type CurrencySelectProps = {
onValueChange: (value: string) => void;
value: string | undefined;
};
export function CurrencySelect({ onValueChange, value }: CurrencySelectProps) {
return (
<Select onValueChange={onValueChange} value={value}>
<SelectTrigger>
<SelectValue placeholder="Choose a currency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="usd">US dollar</SelectItem>
<SelectItem value="vnd">Vietnamese dong</SelectItem>
</SelectContent>
</Select>
);
}
  1. Parts must render inside their root. They read React context, not props you thread manually. A part rendered outside its root has no context to read, and the failure is a runtime error or dead interaction rather than a type error.
  2. Do not hoist a trigger out of its family. Moving SelectTrigger into a shared header component and leaving SelectContent behind breaks anchoring, dismissal arbitration and focus restoration at once. If you need a custom trigger surface, put your custom node inside SelectTrigger.
  3. Composition is where you customize; variants are how you style. Reach for variant, size and tone props first; use className when the engine escape hatch is genuinely needed, knowing it is not a portability promise.
  4. The root is the state boundary. Selection, open state and dismissal belong to the root — see State model.
  5. Every part is a public export of @beemvp/beeui-ui. If a part is not exported from the package barrel, it is internal and not part of the contract, even if the file resolves.
  6. children is the extension point. Adding your own nodes inside a part is expected. Replacing a part with your own component that mimics it is not, because the context contract goes with the part.
  7. Do not nest one pressable inside another. A trigger part (SelectTrigger, DropdownMenuTrigger, PopoverTrigger, TooltipTrigger, SheetTrigger, DialogTrigger, AlertDialogTrigger) is itself the pressable — it carries the same variant/size/press API as Button — so an icon or label goes directly inside it, never wrapped in a second IconButton. The same rule holds wherever a family exposes a pressable slot: ListItem’s trailing, or a SettingsItem composed with AlertDialogTrigger. On Web, nesting two pressables renders one interactive element inside another, which React flags as invalid DOM nesting; reach for variant="ghost" on the outer one instead.
  • Your product component wraps the whole family, not a single part. CurrencySelect above is the right granularity; a CurrencySelectTrigger that lives in another file is not.

  • List rendering happens inside the surface part, where it belongs:

    <SelectContent>
    {currencies.map((currency) => (
    <SelectItem key={currency.code} value={currency.code}>
    {currency.label}
    </SelectItem>
    ))}
    </SelectContent>
  • Conditional parts are fine; conditional roots remount and reset uncontrolled state. Keep the root mounted and drive it with props instead.

  • Because parts are ordinary components, layout composition (Stack, HStack, VStack, Box) works inside them without special cases.

“These are just wrapper divs, so I can move them around freely.”

They are not structural — they are context consumers with behavior attached. The most common failure is a “reusable trigger” abstraction that separates a trigger from its content: nothing type-checks as broken, the button renders, and then the overlay positions against the window instead of the anchor, or Escape closes the wrong layer. Keep the family together and pass configuration through props.

The second most common failure is re-implementing a part. If SelectItem does not do what you need, put your content inside it rather than writing a substitute — the accessibility semantics and selection reporting live in the part you replaced.