Compound Components
Compound components are components designed to work together under a shared parent.
They let users write expressive JSX while the parent coordinates shared state.
Common examples include:
- tabs
- accordions
- menus
- radio groups
- select components
- dialogs
The Goal
Instead of this:
<Tabs
tabs={[
{ label: "Profile", content: <Profile /> },
{ label: "Billing", content: <Billing /> },
]}
/>you can offer this:
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">Profile</Tabs.Trigger>
<Tabs.Trigger value="billing">Billing</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="profile"><Profile /></Tabs.Panel>
<Tabs.Panel value="billing"><Billing /></Tabs.Panel>
</Tabs>The markup is flexible, but the pieces still share behavior.
Using Context
Compound components often use context internally.
const TabsContext = React.createContext(null);
function Tabs({ defaultValue, children }) {
const [value, setValue] = useState(defaultValue);
return (
<TabsContext.Provider value={{ value, setValue }}>
{children}
</TabsContext.Provider>
);
}Child components read the shared state.
function TabsTrigger({ value, children }) {
const context = useContext(TabsContext);
if (!context) {
throw new Error("Tabs.Trigger must be used inside Tabs");
}
const selected = context.value === value;
return (
<button
aria-selected={selected}
onClick={() => context.setValue(value)}
>
{children}
</button>
);
}Attach child components as properties for a clear API.
Tabs.Trigger = TabsTrigger;Controlled and Uncontrolled Compound Components
Reusable components often support two modes.
Uncontrolled:
<Tabs defaultValue="profile">...</Tabs>The component owns its state.
Controlled:
<Tabs value={tab} onValueChange={setTab}>...</Tabs>The parent owns the state.
A component can support both:
function Tabs({ value, defaultValue, onValueChange, children }) {
const [internalValue, setInternalValue] = useState(defaultValue);
const isControlled = value !== undefined;
const selectedValue = isControlled ? value : internalValue;
function selectValue(nextValue) {
if (!isControlled) {
setInternalValue(nextValue);
}
onValueChange?.(nextValue);
}
return (
<TabsContext.Provider value={{ value: selectedValue, selectValue }}>
{children}
</TabsContext.Provider>
);
}Avoid switching between controlled and uncontrolled mode after mount. Pick one for the lifetime of the component.
Accessibility Matters
Compound components often represent interactive widgets.
Use semantic HTML and ARIA carefully.
For tabs, think about:
role="tablist"role="tab"role="tabpanel"- keyboard navigation
- focus management
- linking triggers to panels with IDs
The pattern is not just about API shape. It also needs correct behavior.
Common Mistakes
- Building a compound API before the component needs that flexibility.
- Forgetting to handle missing context with a helpful error.
- Mixing controlled and uncontrolled state accidentally.
- Making child components depend on fragile child order.
- Ignoring accessibility requirements for menus, tabs, and dialogs.
In a controlled compound component, where does the selected value live?
Recap
Compound components create flexible component families with shared state.
They are powerful for UI primitives, but they require careful API design, context handling, and accessibility.
Practice
Build an uncontrolled Accordion with Accordion.Item, Accordion.Header, and Accordion.Panel.
Then add a controlled value and onValueChange API.