Compound Component Pattern in React
React components can be designed in many ways. You can pass everything as props, render children conditionally, or let consumers compose pieces together. The compound component pattern takes the composition approach, giving consumers full control over structure while keeping related components grouped under a single API.
A compound component is a set of components that work together to form a complete UI element. Instead of a single component with many props controlling its internals, you expose multiple smaller components that consumers arrange as needed.
The Prop Version
A prop-driven dialog can be fine when the structure is fixed.
<Dialog
title="Delete project"
description="This cannot be undone."
footer={<Button>Delete</Button>}
/>
This API is compact. The caller passes data, and the component decides where everything goes.
The problem appears when the caller needs a slightly different layout. Maybe the title needs a badge. Maybe the footer needs two groups of actions. Maybe one dialog has no description, but another has explanatory content between the title and the body.
You can keep adding props, but each prop makes the component responsible for another layout decision.
The Compound Version
The compound version exposes the pieces instead.
<Dialog>
<Dialog.Header>
<Dialog.Title>Delete project</Dialog.Title>
<Dialog.Close />
</Dialog.Header>
<Dialog.Description>This cannot be undone.</Dialog.Description>
<Dialog.Footer>
<Button>Cancel</Button>
<Button>Delete</Button>
</Dialog.Footer>
</Dialog>
The component still provides the building blocks. The caller decides how to compose them.
This is the main trade-off. Compound components ask the caller to write more JSX, but they avoid turning layout variations into a long prop list.
Build the Pieces First
Start with plain components. There is nothing special about them yet.
components/dialog.tsx import type { ComponentProps, ReactNode } from "react"; import { cn } from "~/cn"; interface DialogRootProps { children: ReactNode; className?: string; } interface DialogTitleProps extends ComponentProps<"h2"> {} function DialogRoot({ children, className }: DialogRootProps) { return <div className={cn("dialog", className)}>{children}</div>; } function DialogHeader({ className, ...props }: ComponentProps<"header">) { return <header {...props} className={cn("dialog-header", className)} />; } function DialogTitle({ className, ...props }: DialogTitleProps) { return <h2 {...props} className={cn("dialog-title", className)} />; } function DialogDescription({ className, ...props }: ComponentProps<"p">) { return <p {...props} className={cn("dialog-description", className)} />; } function DialogFooter({ className, ...props }: ComponentProps<"footer">) { return <footer {...props} className={cn("dialog-footer", className)} />; }
Each piece owns its element and styling. The root does not inspect its children, and the children do not need to know about every other child.
Attach the API
Attach the pieces to the root component with Object.assign.
components/dialog.tsx export const Dialog = Object.assign(DialogRoot, { Header: DialogHeader, Title: DialogTitle, Description: DialogDescription, Footer: DialogFooter, });
This creates the Dialog.Title API without putting runtime values inside a TypeScript namespace. The value is just a function with properties attached to it.
The type also follows from the assigned object. Consumers get autocomplete for Dialog.Header, Dialog.Title, and the rest without importing each piece separately.
Share State Only When Needed
Many compound components do not need shared state. A card, dialog layout, or page header can be a set of styled pieces.
Tabs are different. The list and panels need to agree on the selected tab. That is when context becomes useful.
components/tabs.tsx import type { ReactNode } from "react"; import { createContext, useContext, useState } from "react"; interface TabsContextValue { activeTab: string; setActiveTab(value: string): void; } let TabsContext = createContext<TabsContextValue | null>(null); function useTabs() { let context = useContext(TabsContext); if (!context) throw new Error("Tabs components must be used inside Tabs"); return context; } function TabsRoot({ children, defaultTab }: { children: ReactNode; defaultTab: string }) { let [activeTab, setActiveTab] = useState(defaultTab); let value = { activeTab, setActiveTab }; return <TabsContext value={value}>{children}</TabsContext>; }
Context should serve the component relationship, not hide application state. If the state matters outside the component family, make it controlled instead.
Accessibility Still Matters
Compound components can make accessibility easier to organize, but they do not make it automatic.
For simple layout pieces, use the right HTML elements. For interactive components, build on accessible primitives when possible. For example, React Aria Components lets each piece keep the visible API while delegating the difficult behavior.
components/dialog.tsx import { Heading } from "react-aria-components"; function DialogTitle({ className, ...props }: DialogTitleProps) { return <Heading {...props} slot="title" className={cn("dialog-title", className)} />; }
Here, slot="title" connects the title to the dialog semantics. The caller still writes Dialog.Title, but the component handles the accessibility wiring.
When Not to Use It
Compound components are a bad fit when the structure is always the same.
A button probably does not need Button.Icon and Button.Label if every button renders icon, label, and spinner in one fixed order. Props are simpler there.
They are also a bad fit when the children must follow strict rules that JSX cannot make obvious. If most invalid arrangements fail at runtime, the API may be too loose.
Use compound components when callers need to rearrange, omit, or extend pieces. Use props when callers only need to configure a stable structure.
Conclusion
The compound component pattern is a way to move layout decisions out of props and back into JSX. It works well for dialogs, cards, menus, command palettes, and other UI where the internal structure changes by use case.
The trade-off is more JSX and a wider component API. That is worth it when the alternative is a component full of layout props. For simple components, keep the prop API and move on.
Do you like my content?
Your sponsorship helps me create more tutorials, articles, and open-source tools.