Skip to content

React Tutorial Advanced React Concepts

Compound Components in React: Building a Tabs Component

Lesson 118 of 156 2 min read By Deepak Paswan Updated Oct 10, 2026

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:

App.jsx
JSX
<Tabs tabs={[{ label: "One", content: ... }, { label: "Two", content: ... }]} />

compound components let the user write the structure directly:

App.jsx
JSX
<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.

App.jsx
JSX
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:

  • Tabs owns the active id, and provides it. It renders no markup of its own.
  • Tabs.Tab reads the state to show whether it is selected and updates it on click.
  • Tabs.Panel renders only when its id is active.
  • useTabs hides the context, with a guard for misuse.
  • ARIA roles (tablist, tab, tabpanel) with aria-selected, aria-controls and aria-labelledby make the widget understandable to assistive technology. The WAI-ARIA Authoring Practices also expect arrow-key navigation between tabs; adding onKeyDown handlers for ArrowLeft and ArrowRight is 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.