You've used <Tabs> where you write <Tabs.Tab> and <Tabs.Panel> and they magically stay in sync — you never pass an active prop or an onChange between them. That's the compound component pattern, and once you've seen the ~20 lines behind it, you'll reach for it constantly. So I built an interactive page that shows the pattern, the implementation, and the mess it replaces.
▶ Live demo: https://compound-components-omega.vercel.app/
Source: https://github.com/dev48v/compound-components
The markup you get to write
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="pricing">Pricing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">…</Tabs.Panel>
<Tabs.Panel value="pricing">…</Tabs.Panel>
</Tabs>
No state in the caller. No active prop. No onChange. Clicking a tab switches the panel. How?
The whole trick is Context
The parent owns the state and shares it; the children read it:
const TabsContext = createContext(null);
function Tabs({ defaultValue, children }) {
const [active, setActive] = useState(defaultValue);
return (
<TabsContext.Provider value={{ active, setActive }}>
{children}
</TabsContext.Provider>
);
}
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("<Tabs.Tab> must be used inside <Tabs>");
return ctx;
}
Tabs.List = ({ children }) => <div role="tablist">{children}</div>;
Tabs.Tab = ({ value, children }) => {
const { active, setActive } = useTabs();
return (
<button aria-selected={active === value} onClick={() => setActive(value)}>
{children}
</button>
);
};
Tabs.Panel = ({ value, children }) => {
const { active } = useTabs();
return active === value ? <div>{children}</div> : null;
};
Three things make it work:
-
The parent owns the state.
activelives in<Tabs>and goes into a context provider. -
Children consume it implicitly. Every
TabandPanelcallsuseTabs()— they read and update the shared value without the caller wiring anything. -
Sub-components hang off the parent (
Tabs.Tab = …). That namespacing is the "compound" part: it signals the pieces belong together, and theuseTabs()guard throws a clear error if someone uses aTaboutside aTabs.
A nice consequence: any descendant can read the context, at any depth. In the demo there's an extra little component buried in the tree that just prints active = "pricing" — no props reached it; it asked the context directly.
What it replaces
Without the pattern, the caller holds the state and threads it onto every element:
const [active, setActive] = useState("overview");
<Tab value="overview" active={active} onChange={setActive}>…</Tab>
<Tab value="pricing" active={active} onChange={setActive}>…</Tab>
<Panel value="overview" active={active}>…</Panel>
<Panel value="pricing" active={active}>…</Panel>
Every element repeats active / onChange. Add a tab and you wire it again. The compound version moves all of that inside <Tabs>, so the caller only declares structure. This is exactly how Radix UI, Reach UI, and Headless UI build their primitives.
It's a reusable technique, not a one-off
The demo also builds an Accordion with the identical approach — a parent with createContext + useState, items that useContext — just a different shape of state (which item is open). Once you see Tabs and Accordion share the same skeleton, you start seeing it everywhere: menus, selects, radio groups, steppers, dialogs.
When to reach for it
When a set of components has to coordinate one piece of state and you want the usage to read as plain markup. If the components are genuinely independent, plain props are still simpler — don't add a context to pass one value one level down.
Flip the tabs, expand the accordion, and read the implementation side by side in the demo. If it made the pattern click, a star helps others find it: https://github.com/dev48v/compound-components
Top comments (0)