Compound Components
How @diffgazer/ui uses the compound component pattern with React Context for Dialog, Tabs, Menu, and more.
What is a compound component?
A compound component is a component split across multiple sub-components that share implicit state through React Context. Think of HTML's native <select> and <option> elements -- they only make sense together, and the parent manages shared state that the children consume.
In @diffgazer/ui, several components use this pattern: Dialog, Tabs, Menu, and NavigationList. Each has a root component that provides context and child components that consume it.
Stepper also uses the compound structure (Stepper > StepperStep > StepperSubstep) and shares expansion state through context, with one direct-child detection rule described below.
Anatomy
Dialog
Dialog is the most complete example of the compound pattern:
Each sub-component has a specific role but relies on the shared context from Dialog to function.
Tabs
How context works
Every compound component follows the same pattern. Here's how Dialog implements it:
1. Define the context type
2. Create the context with undefined default
3. Create a hook with error boundary
This ensures you get a clear error message if you accidentally use a sub-component outside its root.
4. Root component provides the context
5. Child components consume the context
This is the same pattern used by Tabs, Menu, NavigationList, and Stepper. The root provides, the children consume.
Controlled vs uncontrolled
All compound components support both controlled and uncontrolled usage.
Uncontrolled
The component manages its own state internally. You just set defaults:
Controlled
You own the state and pass it in:
The same pattern applies across components:
- Dialog:
open/onOpenChange - Tabs:
value/onChange - Menu:
selectedId/onSelect - NavigationList:
selectedId/onSelect, with optionalhighlighted/onHighlightChangefor controlled keyboard highlight
Composition
The compound pattern gives you full control over structure. You can:
Skip sub-components you don't need:
Wrap sub-components in your own components:
DialogClose already renders a Button and accepts Button props directly, so pass props such as variant to DialogClose instead of nesting a Button.
Context-only parts such as Dialog body, footer, and close controls can live inside custom wrapper components because those parts read the root context at render time. Metadata-scanned components have a narrower contract: item-defining parts for Tabs, Select, CommandPalette, Menu, NavigationList, RadioGroup, and ToggleGroup must appear as explicit children in that component's JSX tree. Put custom item UI inside the item part instead of using an opaque wrapper that creates items internally.
Toast: store and Toaster
Toast is not a compound component in the current API. It exposes an imperative toast() store and a Toaster renderer:
Place one Toaster near the app root. Calls to toast(), toast.success(), toast.error(), toast.warning(), toast.info(), toast.loading(), and toast.promise() update the shared store. Error and loading toasts persist when duration is omitted. A positive explicit duration schedules auto-dismissal.
Stepper: direct content detection
Stepper uses context for expansion state, but StepperStep only detects StepperContent when it is a direct child of that step:
Keep StepperContent directly inside StepperStep when the trigger should expose aria-controls. A wrapper that creates StepperContent internally is not part of the current public contract.
Summary
Note: MenuItem uses children for its label: <MenuItem id="copy">Copy</MenuItem>, not a label prop.