Runnable lesson · press Try it on the examples to edit and run them here.
Look at the HTML <select> and <option>. Neither makes sense alone, and together they form one control that shares state (the selected value). Compound components bring this design to React: a set of components that work together and share state implicitly, so users of the component write simple, readable markup.
The goal: a nice API
Instead of one component with many props:
<Tabs tabs={[{ label: "One", content: ... }, { label: "Two", content: ... }]} />
compound components let the user write the structure directly:
<Tabs>
<Tabs.List>
<Tabs.Tab id="one">One</Tabs.Tab>
<Tabs.Tab id="two">Two</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="one">First panel</Tabs.Panel>
<Tabs.Panel id="two">Second panel</Tabs.Panel>
</Tabs>
This is flexible (you can put anything between the parts), and each part has a small job.
How to build it
The parent holds the state and shares it through Context. The children read it with a hook. The user never passes the state around.
const TabsContext = React.createContext(null);
function useTabs() {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error("Tabs parts must be used inside <Tabs>");
return ctx;
}
function Tabs({ defaultId, children }) {
const [activeId, setActiveId] = React.useState(defaultId);
const baseId = React.useId();
const value = React.useMemo(() => ({ activeId, setActiveId, baseId }), [activeId, baseId]);
return <TabsContext value={value}>{children}</TabsContext>;
}
function List({ children }) {
return <div role="tablist" style={{ display: "flex", gap: 4, borderBottom: "1px solid #ccc" }}>{children}</div>;
}
function Tab({ id, children }) {
const { activeId, setActiveId, baseId } = useTabs();
const selected = activeId === id;
return (
<button
role="tab"
id={`${baseId}-tab-${id}`}
aria-selected={selected}
aria-controls={`${baseId}-panel-${id}`}
tabIndex={selected ? 0 : -1}
onClick={() => setActiveId(id)}
style={{ padding: "6px 14px", border: 0, background: selected ? "#1a5276" : "transparent", color: selected ? "#fff" : "#1a5276", cursor: "pointer" }}
>
{children}
</button>
);
}
function Panel({ id, children }) {
const { activeId, baseId } = useTabs();
if (activeId !== id) return null;
return (
<div role="tabpanel" id={`${baseId}-panel-${id}`} aria-labelledby={`${baseId}-tab-${id}`} style={{ padding: 12 }}>
{children}
</div>
);
}
// Attach the parts as properties of Tabs
Tabs.List = List;
Tabs.Tab = Tab;
Tabs.Panel = Panel;
function App() {
return (
<Tabs defaultId="overview">
<Tabs.List>
<Tabs.Tab id="overview">Overview</Tabs.Tab>
<Tabs.Tab id="features">Features</Tabs.Tab>
<Tabs.Tab id="pricing">Pricing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="overview">A tiny tabs component.</Tabs.Panel>
<Tabs.Panel id="features">Fast and small.</Tabs.Panel>
<Tabs.Panel id="pricing">Free forever.</Tabs.Panel>
</Tabs>
);
}
What each part does:
Tabsowns the active id, and provides it. It renders no markup of its own.Tabs.Tabreads the state to show whether it is selected and updates it on click.Tabs.Panelrenders only when its id is active.useTabshides the context, with a guard for misuse.- ARIA roles (
tablist,tab,tabpanel) witharia-selected,aria-controlsandaria-labelledbymake the widget understandable to assistive technology. The WAI-ARIA Authoring Practices also expect arrow-key navigation between tabs; addingonKeyDownhandlers forArrowLeftandArrowRightis a good exercise.
Why use this pattern?
- A flexible, readable API: the markup shows the structure.
- Hidden shared state: users do not wire props between parts.
- Customisable layout: put icons, wrappers or extra elements anywhere.
- It is how many component libraries are built (menus, accordions, select boxes, dialogs).
Costs and cautions
- More code than a simple prop-based component.
- The parts only work together. Clear errors help (“must be used inside Tabs”), as does documentation.
- Context re-renders all parts when the active id changes. That is fine for tabs.
- If you need advanced accessibility (focus management, typeahead), consider an unstyled accessible component library rather than writing everything yourself. Learning the pattern helps you use and customise them.
Another example: an accordion item
The same idea applies to any group with shared state. An accordion’s parent can track which section is open, and each item reads and sets it through context.
Key points
- Compound components are related parts that share state through Context.
- Attach parts as properties (
Tabs.Tab) and guard the hook against misuse. - Build in the correct ARIA roles and keyboard behaviour, or use a trusted library.