Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 073 / 100

Compound components: a practical Design Patterns guide

Components that work together through shared context.

Expona Admin8 min read

Introduction

You've probably used a component library like Radix UI, Headless UI, or Material UI and noticed how their tabs work: <Tabs><Tabs.List><Tabs.Trigger></Tabs.List><Tabs.Content>. That's not accidental API design—it's a pattern called compound components: a practical Design Patterns guide that solves a real problem in modern UI development. Compound components are components that work together through shared context, creating flexible markup structures while keeping state management clean and centralized. In this article, we'll explore when and how to build them, walk through concrete examples, and show why this pattern has become the foundation of headless UI libraries.

What are compound components?

A compound component is a set of related components that share internal state and logic but expose a flexible markup structure. Unlike a monolithic component that takes many props, compound components let you compose smaller pieces that feel natural in JSX while coordinating behind the scenes.

The key difference is flexibility. Compare these two approaches:

typescript
// Monolithic approach
<Tabs items={[{label: 'Tab 1', content: '...'}, ...]} activeTab={0} />

// Compound approach
<Tabs value={activeTab} onChange={setActiveTab}>
  <Tabs.List>
    <Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
    <Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="tab1">Content 1</Tabs.Panel>
  <Tabs.Panel value="tab2">Content 2</Tabs.Panel>
</Tabs>

The compound approach gives you fine-grained control. You can add custom markup between triggers, nest panels differently, or reorder them—all without adding new props to the component.

Tabs, Tabs.List, and Tabs.Panel: a real-world structure

Let's build a real tabs component to ground this. We'll create a product details page where tabs contain specifications, reviews, and shipping info.

typescript
// TabsContext.ts
import { createContext, useContext } from 'react';

interface TabsContextValue {
  value: string;
  onChange: (value: string) => void;
}

const TabsContext = createContext<TabsContextValue | undefined>(undefined);

export const useTabsContext = () => {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error('Tabs compound components must be used within <Tabs>');
  }
  return context;
};

export { TabsContext };
typescript
// Tabs.tsx
import { ReactNode } from 'react';
import { TabsContext } from './TabsContext';

interface TabsProps {
  value: string;
  onChange: (value: string) => void;
  children: ReactNode;
}

export const Tabs = ({ value, onChange, children }: TabsProps) => {
  return (
    <TabsContext.Provider value={{ value, onChange }}>
      <div role="tablist" className="tabs-root">
        {children}
      </div>
    </TabsContext.Provider>
  );
};

interface TabsListProps {
  children: ReactNode;
}

Tabs.List = ({ children }: TabsListProps) => {
  return <div className="tabs-list" role="presentation">{children}</div>;
};

interface TabsTriggerProps {
  value: string;
  children: ReactNode;
}

Tabs.Trigger = ({ value, children }: TabsTriggerProps) => {
  const { value: activeValue, onChange } = useTabsContext();

  return (
    <button
      role="tab"
      aria-selected={activeValue === value}
      onClick={() => onChange(value)}
      className={`tabs-trigger ${activeValue === value ? 'active' : ''}`}
    >
      {children}
    </button>
  );
};

interface TabsPanelProps {
  value: string;
  children: ReactNode;
}

Tabs.Panel = ({ value, children }: TabsPanelProps) => {
  const { value: activeValue } = useTabsContext();

  if (activeValue !== value) return null;

  return (
    <div role="tabpanel" className="tabs-panel">
      {children}
    </div>
  );
};

Now you can use it with full control over markup:

typescript
const [activeTab, setActiveTab] = useState('specs');

<Tabs value={activeTab} onChange={setActiveTab}>
  <Tabs.List>
    <Tabs.Trigger value="specs">Specifications</Tabs.Trigger>
    <Tabs.Trigger value="reviews">Reviews (42)</Tabs.Trigger>
    <Tabs.Trigger value="shipping">Shipping</Tabs.Trigger>
  </Tabs.List>

  <Tabs.Panel value="specs">
    <table>...</table>
  </Tabs.Panel>

  <Tabs.Panel value="reviews">
    <ReviewList />
  </Tabs.Panel>

  <Tabs.Panel value="shipping">
    <ShippingDetails />
  </Tabs.Panel>
</Tabs>

Each sub-component is simple and focused. The root Tabs manages state and provides context. Tabs.Trigger reads context to know if it's active and calls onChange. Tabs.Panel only renders if its value matches the active tab.

Flexible markup, shared state

The real power of this pattern emerges when requirements change. Say your design team wants to add an icon next to each trigger and a description underneath:

typescript
<Tabs.List>
  <Tabs.Trigger value="specs">
    <IconSpec />
    <div>
      <strong>Specifications</strong>
      <small>Technical details</small>
    </div>
  </Tabs.Trigger>
  <Tabs.Trigger value="reviews">
    <IconReview />
    <div>
      <strong>Reviews</strong>
      <small>42 customer reviews</small>
    </div>
  </Tabs.Trigger>
</Tabs.List>

No changes to Tabs.Trigger itself. The children prop accepts any JSX. Compare this to a monolithic component—you'd need new props like icon, description, descriptionVisible, and so on. Compound components scale gracefully because you only share what's essential: the active state and the toggle handler.

Another scenario: your product page needs to lazy-load tab content from an API. With compound components, you can wrap Tabs.Panel with your own async logic:

typescript
const LazyPanel = ({ value, loader }: { value: string; loader: () => Promise<JSX.Element> }) => {
  const [content, setContent] = useState<JSX.Element | null>(null);
  const { value: activeValue } = useTabsContext();

  useEffect(() => {
    if (activeValue === value) {
      loader().then(setContent);
    }
  }, [activeValue, value]);

  return (
    <Tabs.Panel value={value}>
      {content || <Spinner />}
    </Tabs.Panel>
  );
};

// Usage
<LazyPanel
  value="reviews"
  loader={() => fetch('/api/reviews').then(r => r.json()).then(data => <ReviewList data={data} />)}
/>

This works because Tabs.Panel is simple—it's just conditional rendering. You extend behavior by composing, not by modifying the component.

Context connects the parts

Context is the glue holding compound components together. Every child knows the active tab value and how to change it without prop drilling.

When you design a compound component, think about what state needs to be shared:

  • Active value: which tab is open?
  • Callbacks: what happens when a trigger is clicked?
  • Optional metadata: disabled triggers, orientation (horizontal vs. vertical), focus management

Here's a more complete example with disabled tabs and focus management:

typescript
interface TabsContextValue {
  value: string;
  onChange: (value: string) => void;
  disabled?: string[];
  orientation: 'horizontal' | 'vertical';
}

Tabs.Trigger = ({ value, children, disabled: isDisabled }: TabsTriggerProps & { disabled?: boolean }) => {
  const { value: activeValue, onChange, disabled: disabledTabs, orientation } = useTabsContext();
  const isDisabledOverall = isDisabled || disabledTabs?.includes(value);

  const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
    if (orientation === 'horizontal' && e.key === 'ArrowRight') {
      // Move to next enabled tab
    } else if (orientation === 'vertical' && e.key === 'ArrowDown') {
      // Move to next enabled tab
    }
  };

  return (
    <button
      role="tab"
      aria-selected={activeValue === value}
      aria-disabled={isDisabledOverall}
      onClick={() => !isDisabledOverall && onChange(value)}
      onKeyDown={handleKeyDown}
      disabled={isDisabledOverall}
      className={`tabs-trigger ${activeValue === value ? 'active' : ''}`}
    >
      {children}
    </button>
  );
};

Context scales with complexity. As requirements grow (keyboard navigation, animation callbacks, nested tabs), you add to context once, and all children automatically receive those capabilities. No prop drilling, no wrapper hell.

Used by most headless UI libraries

Radix UI, Headless UI, shadcn/ui, and most modern component libraries use this pattern because it works. Here's why teams adopt it:

  • Semantic HTML: The API mirrors DOM structure. Tabs contain a list of triggers and panels. It reads naturally.
  • Accessibility out of the box: Libraries handle ARIA attributes, keyboard navigation, and focus management in the compound components. You don't repeat it per integration.
  • Style flexibility: Because markup is under your control, styling is straightforward. You own the class names and DOM structure.
  • Composition over configuration: Instead of 50 props, you compose elements. This keeps APIs discoverable and predictable.

When you inspect Radix's tabs source, you'll see the same pattern: a root component providing context, and specialized child components consuming it. The difference is that Radix adds robust a11y, animation support, and multi-platform consistency. But the pattern is identical.

Common mistakes

1. Forgetting to provide context. If a child component is rendered outside the root, it throws because context is undefined. Always validate context at the hook level with a clear error message.

2. Making child components too smart. Resist the urge to add business logic to Tabs.Trigger or Tabs.Panel. They should be thin wrappers that read context and render. Complex logic lives in the parent or a custom wrapper.

3. Exposing too much context. Each child only needs what it uses. If Tabs.Panel never needs orientation, don't include it in context. Keep the shape minimal to avoid coupling.

4. Tightly coupling sub-components. A user should be able to import and use Tabs.Trigger independently (even if it throws without context). Avoid hard dependencies between sub-components.

5. Not documenting the required props. Unlike a monolithic component with one prop interface, compound components split props across multiple entry points. Document which child components are optional (e.g., Tabs.List) and which are required.

Key takeaways

  • Compound components share state through context, enabling flexible markup without prop explosion.
  • The pattern works best when you have related, hierarchical components (like tabs, dropdowns, select menus, accordions).
  • Context should only expose what's truly shared; keep the shape minimal and clearly documented.
  • Child components should be simple and focused—rendering, event handling, and reading context. Complex logic belongs in wrappers or the parent.
  • Always validate context availability in child hooks and provide clear error messages when consumers are misused.
  • This pattern scales gracefully: new markup requirements don't require new props, only new JSX structure.
  • Most headless UI libraries use compound components because they balance flexibility, accessibility, and API clarity.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles