Compound Component Pattern
The HTML Precedent
You already know compound components. You use them every day:
<select>
<option value="react">React</option>
<option value="vue">Vue</option>
<option value="svelte">Svelte</option>
</select>
select and option are a compound component. Neither is useful alone. Together, they create a complete interaction. The select manages state (which option is selected). The option elements provide choices. They communicate implicitly — you never pass selectedValue to each option manually.
This is the compound component pattern: a set of components that work together, sharing state implicitly through context, while exposing a clean, declarative API to consumers.
Think of compound components like a band. The drummer, guitarist, and vocalist are separate components. They share a common tempo and key (the context). Each player controls their own part (their own rendering), but they are musically connected. You do not tell each musician what the others are playing — they share the context and coordinate automatically.
The Problem Compound Components Solve
Without compound components, you end up with one of two bad alternatives:
// BAD OPTION 1: Prop explosion
<Tabs
tabs={[
{ label: "Code", content: <CodeEditor />, icon: <CodeIcon />, disabled: false },
{ label: "Preview", content: <Preview />, icon: <EyeIcon />, disabled: false },
{ label: "Tests", content: <TestRunner />, icon: <TestIcon />, disabled: true },
]}
defaultTab={0}
orientation="horizontal"
onTabChange={handleChange}
/>
// BAD OPTION 2: Prop drilling
<Tabs defaultTab={0} onTabChange={handleChange}>
<TabList>
<Tab index={0} selectedIndex={selectedIndex} onSelect={onSelect}>Code</Tab>
<Tab index={1} selectedIndex={selectedIndex} onSelect={onSelect}>Preview</Tab>
<Tab index={2} selectedIndex={selectedIndex} onSelect={onSelect} disabled>Tests</Tab>
</TabList>
<TabPanels selectedIndex={selectedIndex}>
<TabPanel index={0}><CodeEditor /></TabPanel>
<TabPanel index={1}><Preview /></TabPanel>
<TabPanel index={2}><TestRunner /></TabPanel>
</TabPanels>
</Tabs>
Option 1 is rigid. You cannot customize how individual tabs render. Option 2 is verbose — the consumer must thread selectedIndex and onSelect through every child manually.
Compound components give you the flexibility of Option 2 with the simplicity of:
// COMPOUND — clean, flexible, no manual state threading
<Tabs defaultValue="code">
<TabList>
<Tab value="code"><CodeIcon /> Code</Tab>
<Tab value="preview"><EyeIcon /> Preview</Tab>
<Tab value="tests" disabled><TestIcon /> Tests</Tab>
</TabList>
<TabPanel value="code"><CodeEditor /></TabPanel>
<TabPanel value="preview"><Preview /></TabPanel>
<TabPanel value="tests"><TestRunner /></TabPanel>
</Tabs>
No index tracking. No prop drilling. Each Tab and TabPanel knows which one is active through shared context.
Building Compound Tabs Step by Step
Step 1: The Context
Every compound component starts with a shared context:
interface TabsContextValue {
activeValue: string;
onValueChange: (value: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const context = useContext(TabsContext);
if (!context) {
throw new Error("Tab components must be used within a Tabs provider");
}
return context;
}
That error message is important. If someone uses Tab outside of Tabs, they get a clear error instead of a mysterious Cannot read property of undefined.
Step 2: The Root Component
The root component owns the state and provides context:
interface TabsProps {
defaultValue: string;
value?: string;
onValueChange?: (value: string) => void;
children: ReactNode;
}
function Tabs({ defaultValue, value, onValueChange, children }: TabsProps) {
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = value !== undefined;
const activeValue = isControlled ? value : internalValue;
const handleValueChange = useCallback(
(next: string) => {
if (!isControlled) setInternalValue(next);
onValueChange?.(next);
},
[isControlled, onValueChange],
);
const contextValue = useMemo(
() => ({ activeValue, onValueChange: handleValueChange }),
[activeValue, handleValueChange],
);
return (
<TabsContext value={contextValue}>
<div>{children}</div>
</TabsContext>
);
}
Notice: this supports both controlled and uncontrolled usage. The consumer can pass value and onValueChange for full control, or just defaultValue for fire-and-forget.
Step 3: The Child Components
Each child reads from context:
interface TabProps {
value: string;
disabled?: boolean;
children: ReactNode;
}
function Tab({ value, disabled, children }: TabProps) {
const { activeValue, onValueChange } = useTabsContext();
const isActive = activeValue === value;
const tabId = `tab-${value}`;
const panelId = `panel-${value}`;
return (
<button
id={tabId}
role="tab"
aria-selected={isActive}
aria-disabled={disabled}
aria-controls={panelId}
tabIndex={isActive ? 0 : -1}
onClick={() => {
if (!disabled) onValueChange(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!disabled) onValueChange(value);
}
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
const tabs = Array.from(
e.currentTarget.parentElement?.querySelectorAll<HTMLElement>('[role="tab"]') ?? [],
);
const currentIndex = tabs.indexOf(e.currentTarget);
const nextIndex =
e.key === "ArrowRight"
? (currentIndex + 1) % tabs.length
: (currentIndex - 1 + tabs.length) % tabs.length;
tabs[nextIndex]?.focus();
}
}}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors",
isActive
? "border-b-2 border-indigo-500 text-indigo-600"
: "text-zinc-500 hover:text-zinc-700",
disabled && "cursor-not-allowed opacity-50",
)}
>
{children}
</button>
);
}
function TabPanel({ value, children }: { value: string; children: ReactNode }) {
const { activeValue } = useTabsContext();
const tabId = `tab-${value}`;
const panelId = `panel-${value}`;
if (activeValue !== value) return null;
return (
<div role="tabpanel" id={panelId} aria-labelledby={tabId} tabIndex={0}>
{children}
</div>
);
}
function TabList({ children }: { children: ReactNode }) {
return (
<div role="tablist" className="flex border-b">
{children}
</div>
);
}
Type Safety for Compound Components
The basic context pattern works, but you can make it type-safer with generics:
function createCompoundContext<T>(componentName: string) {
const Context = createContext<T | null>(null);
function useCompoundContext() {
const context = useContext(Context);
if (!context) {
throw new Error(
`${componentName} compound components must be used within ${componentName}`,
);
}
return context;
}
return [Context, useCompoundContext] as const;
}
const [AccordionContext, useAccordionContext] =
createCompoundContext<AccordionContextValue>("Accordion");
This factory eliminates the boilerplate of creating context + hook + error message for every compound component.
Validating Children with displayName
In development, you might want to ensure only valid children are passed to a compound component:
Tab.displayName = "Tab";
TabPanel.displayName = "TabPanel";
TabList.displayName = "TabList";
function Tabs({ children, ...props }: TabsProps) {
if (process.env.NODE_ENV === "development") {
const childArray = Children.toArray(children);
childArray.forEach((child) => {
if (isValidElement(child)) {
const displayName = (child.type as { displayName?: string }).displayName;
const validNames = new Set(["TabList", "TabPanel"]);
if (displayName && !validNames.has(displayName)) {
console.warn(`Tabs: unexpected child ${displayName}. Expected TabList or TabPanel.`);
}
}
});
}
return <TabsContext value={contextValue}>{children}</TabsContext>;
}
displayName validation only works in development. Minifiers strip displayName in production builds. Never rely on it for runtime logic — only for development warnings. Also, Children.toArray and isValidElement do not work with Server Components in React 19. Use these patterns only in client components.
When Compound Beats Prop Drilling
| Scenario | Props API | Compound API |
|---|---|---|
| Simple, fixed structure | Good — fewer components to import | Overkill — adds unnecessary indirection |
| Customizable rendering per part | Bad — needs renderX props for each part | Great — each part is a separate component |
| Conditional parts (optional header) | Awkward — needs showHeader boolean | Natural — just omit the Header component |
| Multiple instances of a part | Impossible without array configs | Natural — render multiple of the same part |
| Reordering parts | Impossible without order prop | Natural — change the JSX order |
The rule of thumb: if a component has more than 2-3 customizable regions, compound components are probably the right choice.
Radix UI's Compound Pattern in Practice
Radix is the gold standard for compound components in React. Look at their Dialog:
<Dialog>
<DialogTrigger asChild>
<Button>Edit Profile</Button>
</DialogTrigger>
<DialogPortal>
<DialogOverlay className="fixed inset-0 bg-black/40" />
<DialogContent className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Make changes to your profile here.</DialogDescription>
<form>
<fieldset>
<label htmlFor="name">Name</label>
<input id="name" defaultValue="John Doe" />
</fieldset>
</form>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
</DialogContent>
</DialogPortal>
</Dialog>
Every piece is a separate component. Want to skip the overlay? Remove DialogOverlay. Want a custom close button? Use DialogClose with asChild. Want to portal to a different container? Wrap DialogPortal with a container prop.
This flexibility is impossible with a props-only API.
- 1Use compound components when a component has 3+ customizable regions
- 2Always provide a context hook with a helpful error message for usage outside the provider
- 3Support both controlled (value + onValueChange) and uncontrolled (defaultValue) modes
- 4Use roving tabindex for keyboard navigation in component groups like tabs
- 5Use displayName for development-only child validation, never for production logic
| What developers do | What they should do |
|---|---|
| Using Children.map and cloneElement to pass props to compound children cloneElement breaks when children are wrapped in fragments, conditionals, or other components. Context works regardless of nesting depth or intermediate wrappers. | Use React Context for implicit state sharing between compound parts |
| Making every component compound — even simple ones like Button or Badge Compound components add API surface (more imports, more components to learn). For single-purpose components, a flat props API is simpler and faster to use. | Reserve compound pattern for multi-part components: Tabs, Accordion, Dialog, Select |
| Storing the entire compound state in a single useState object A single state object causes every child to re-render on any state change. Splitting state lets React skip re-renders for children that only depend on unchanged values. | Split state by concern: one state for active tab, another for disabled tabs, another for orientation |
Design a compound component API for an Accordion that supports: single or multi-expand mode, controlled and uncontrolled state, animated expand/collapse, disabled items, and custom trigger rendering. Show the TypeScript types for the context and the component props. How would you handle the animation without requiring consumers to add CSS?