Skip to content

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:

tsx
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogBody,
  DialogFooter,
  DialogClose,
} from "@/components/ui/dialog"

<Dialog>                          {/* Root: manages open state via Context */}
  <DialogTrigger>Open</DialogTrigger>
  <DialogContent>                 {/* Portal + overlay + modal container */}
    <DialogHeader>                {/* Header wrapper */}
      <DialogTitle>Title</DialogTitle>
      <DialogDescription>Description</DialogDescription>
    </DialogHeader>
    <DialogBody>                  {/* Scrollable content area */}
      {/* Your content */}
    </DialogBody>
    <DialogFooter>                {/* Action buttons */}
      <DialogClose>Cancel</DialogClose>
    </DialogFooter>
  </DialogContent>
</Dialog>

Each sub-component has a specific role but relies on the shared context from Dialog to function.

Tabs

tsx
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"

<Tabs defaultValue="tab1">
  <TabsList>
    <TabsTrigger value="tab1">Tab 1</TabsTrigger>
    <TabsTrigger value="tab2">Tab 2</TabsTrigger>
  </TabsList>
  <TabsContent value="tab1">Content 1</TabsContent>
  <TabsContent value="tab2">Content 2</TabsContent>
</Tabs>

How context works

Every compound component follows the same pattern. Here's how Dialog implements it:

1. Define the context type

tsx
interface DialogContextValue {
  open: boolean
  onOpenChange: (open: boolean) => void
  titleId: string
  descriptionId: string
}

2. Create the context with undefined default

tsx
const DialogContext = createContext<DialogContextValue | undefined>(undefined)

3. Create a hook with error boundary

tsx
function useDialogContext() {
  const context = useContext(DialogContext)
  if (!context) {
    throw new Error("Dialog compound components must be used within a Dialog")
  }
  return context
}

This ensures you get a clear error message if you accidentally use a sub-component outside its root.

4. Root component provides the context

tsx
function Dialog({ children, open, onOpenChange }: DialogProps) {
  return (
    <DialogContext value={{ open, onOpenChange, titleId, descriptionId }}>
      {children}
    </DialogContext>
  )
}

5. Child components consume the context

tsx
function DialogTrigger({ children }: DialogTriggerProps) {
  const { onOpenChange } = useDialogContext()
  return <button onClick={() => onOpenChange(true)}>{children}</button>
}

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:

tsx
{/* Dialog manages its own open state */}
<Dialog>
  <DialogTrigger>Open</DialogTrigger>
  <DialogContent>...</DialogContent>
</Dialog>

{/* Tabs manages its own selected tab */}
<Tabs defaultValue="tab1">
  <TabsList>
    <TabsTrigger value="tab1">Tab 1</TabsTrigger>
    <TabsTrigger value="tab2">Tab 2</TabsTrigger>
  </TabsList>
  <TabsContent value="tab1">Content 1</TabsContent>
  <TabsContent value="tab2">Content 2</TabsContent>
</Tabs>

Controlled

You own the state and pass it in:

tsx
const [open, setOpen] = useState(false)

<Dialog open={open} onOpenChange={setOpen}>
  <DialogContent>...</DialogContent>
</Dialog>

The same pattern applies across components:

  • Dialog: open / onOpenChange
  • Tabs: value / onChange
  • Menu: selectedId / onSelect
  • NavigationList: selectedId / onSelect, with optional highlighted / onHighlightChange for controlled keyboard highlight

Composition

The compound pattern gives you full control over structure. You can:

Skip sub-components you don't need:

tsx
<Dialog open={open} onOpenChange={setOpen}>
  {/* No DialogTrigger -- you control open state yourself */}
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Confirm</DialogTitle>
      {/* No DialogDescription */}
    </DialogHeader>
    <DialogFooter>
      <DialogClose>OK</DialogClose>
    </DialogFooter>
  </DialogContent>
</Dialog>

Wrap sub-components in your own components:

tsx
function ConfirmDialog({ title, onConfirm }) {
  return (
    <Dialog>
      <DialogTrigger>
        {(props) => <Button {...props}>Open</Button>}
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
        </DialogHeader>
        <DialogFooter>
          <DialogClose variant="ghost">Cancel</DialogClose>
          <Button onClick={onConfirm}>Confirm</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

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:

tsx
import { Toaster, toast } from "@/components/ui/toast"

toast.success("Saved")

<Toaster position="bottom-right" />

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:

tsx
<StepperStep stepId="build" status="active">
  <StepperTrigger>Build</StepperTrigger>
  <StepperContent>Build output</StepperContent>
</StepperStep>

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

ComponentRootSub-componentsShared State
DialogDialogDialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogBody, DialogFooter, DialogCloseopen, onOpenChange, titleId, descriptionId
TabsTabsTabsList, TabsTrigger, TabsContentvalue, onChange, orientation
MenuMenuMenuItem, MenuItemCheckbox, MenuItemRadio, MenuDivider, MenuGroup, MenuLabel, MenuSub, MenuSubTrigger, MenuSubContentselectedId, highlighted, onHighlightChange, onSelect, variant
NavigationListNavigationListNavigationListItem, NavigationListTitle, NavigationListStatus, NavigationListMeta, NavigationListBadge, NavigationListSubtitle, NavigationListProgress, NavigationListGroupselectedId, highlighted, onHighlightChange, onSelect, onEnter, onNavigationBoundaryReached, autoFocus, focused
StepperStepperStepperStep, StepperTrigger, StepperContent, StepperSubstepexpandedIds, onExpandedChange
Info:

Note: MenuItem uses children for its label: <MenuItem id="copy">Copy</MenuItem>, not a label prop.