Skip to content

Compound Component Pattern

advanced15 min read

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.

Mental Model

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.

Quiz
What is the main advantage of compound components over a configuration-object API?

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>
  );
}
Quiz
Why does the Tab component use tabIndex={isActive ? 0 : -1} instead of tabIndex={0} for all tabs?

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>;
}
Common Trap

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

ScenarioProps APICompound API
Simple, fixed structureGood — fewer components to importOverkill — adds unnecessary indirection
Customizable rendering per partBad — needs renderX props for each partGreat — each part is a separate component
Conditional parts (optional header)Awkward — needs showHeader booleanNatural — just omit the Header component
Multiple instances of a partImpossible without array configsNatural — render multiple of the same part
Reordering partsImpossible without order propNatural — 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.

Quiz
Which component is the WORST candidate for the compound pattern?

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.

Key Rules
  1. 1Use compound components when a component has 3+ customizable regions
  2. 2Always provide a context hook with a helpful error message for usage outside the provider
  3. 3Support both controlled (value + onValueChange) and uncontrolled (defaultValue) modes
  4. 4Use roving tabindex for keyboard navigation in component groups like tabs
  5. 5Use displayName for development-only child validation, never for production logic
What developers doWhat 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
Interview Question

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?