Skip to content

Compound Components and Slots

Aman Kumar Singh8 min read
Part 13 of 50From the React & Next.js Complete Guide series
Compound Components and Slots — article by Aman Kumar Singh

In React Component Patterns I covered the props-based patterns most teams reach for first: prop drilling fixes, render props, and the classic "one giant config object" API that grows unmaintainable the moment a designer asks for one more variant. This article picks up where that one left off, tackling the pattern that solves the problem those approaches eventually hit: components whose pieces need to share state and layout flexibility without a config object doing all the work.

This is part of the React & Next.js Complete Guide series, which starts at React Fundamentals for Professionals. If you're building a component library or a design system that other teams consume, compound components are one of the most useful patterns you'll add to your toolkit.

The short version: compound components let a parent and its children share implicit state through context, while the consumer controls markup, order, and composition. Slots are the more explicit sibling: a naming convention (or a small API) that lets consumers say "put this content here" without the component needing to know what that content is. Both solve the same underlying tension between flexibility and encapsulation, just with different tradeoffs.

Why prop-based APIs break down

Consider a Tabs component built the naive way, with everything driven by props:

<Tabs
  items={[
    { label: "Account", content: <AccountPanel /> },
    { label: "Billing", content: <BillingPanel /> },
    { label: "Team", content: <TeamPanel /> },
  ]}
  defaultActiveIndex={0}
/>

This works fine until someone needs to disable one tab conditionally, add an icon next to a label, or insert a badge showing unread notifications on the "Team" tab. Each new requirement adds another prop: disabledIndices, icons, badges. The API becomes a config language for describing JSX, which is strictly worse than just writing JSX.

Compound components flip the model. Instead of describing the tabs through data, you compose them as children:

<Tabs defaultValue="account">
  <Tabs.List>
    <Tabs.Trigger value="account">Account</Tabs.Trigger>
    <Tabs.Trigger value="billing">Billing</Tabs.Trigger>
    <Tabs.Trigger value="team">
      Team <Badge count={unreadCount} />
    </Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="account">
    <AccountPanel />
  </Tabs.Content>
  <Tabs.Content value="billing">
    <BillingPanel />
  </Tabs.Content>
  <Tabs.Content value="team">
    <TeamPanel />
  </Tabs.Content>
</Tabs>

Nothing here needed a new prop. The consumer just writes normal JSX, and Tabs coordinates the pieces behind the scenes through context. This is the core value proposition: you stop designing an API for every possible combination of children and instead let composition handle it.

Building the pattern with context

The mechanics are straightforward: a context provider at the top holds shared state, and each sub-component reads from that context instead of receiving props directly.

import { createContext, useContext, useState, type ReactNode } from "react";

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

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

function useTabsContext(componentName: string): TabsContextValue {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error(`${componentName} must be rendered inside <Tabs>`);
  }
  return context;
}

interface TabsProps {
  defaultValue: string;
  children: ReactNode;
}

function Tabs({ defaultValue, children }: TabsProps) {
  const [activeValue, setActiveValue] = useState(defaultValue);
  return (
    <TabsContext.Provider value={{ activeValue, setActiveValue }}>
      <div className="tabs-root">{children}</div>
    </TabsContext.Provider>
  );
}

function TabsList({ children }: { children: ReactNode }) {
  return (
    <div role="tablist" className="tabs-list">
      {children}
    </div>
  );
}

function TabsTrigger({ value, children }: { value: string; children: ReactNode }) {
  const { activeValue, setActiveValue } = useTabsContext("Tabs.Trigger");
  const isActive = activeValue === value;
  return (
    <button
      role="tab"
      aria-selected={isActive}
      data-state={isActive ? "active" : "inactive"}
      onClick={() => setActiveValue(value)}
    >
      {children}
    </button>
  );
}

function TabsContent({ value, children }: { value: string; children: ReactNode }) {
  const { activeValue } = useTabsContext("Tabs.Content");
  if (activeValue !== value) return null;
  return (
    <div role="tabpanel" data-state="active">
      {children}
    </div>
  );
}

Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Content = TabsContent;

export { Tabs };

A few decisions here are worth calling out explicitly rather than leaving implicit:

The useTabsContext helper throws a descriptive error when a sub-component is rendered outside Tabs. Without this, a misused Tabs.Trigger fails with a cryptic "cannot read property of null" somewhere deep in the render, and whoever hits that error spends twenty minutes tracing it back to a missing wrapper. A clear error at the boundary is worth the few extra lines.

Attaching sub-components as static properties (Tabs.List = TabsList) is a naming convention, not a requirement. You could just as easily export TabsList, TabsTrigger, and TabsContent separately. The dot-notation style signals to consumers that these pieces belong together and only make sense inside Tabs, which is a useful discoverability win in an editor with autocomplete.

The role and aria-selected attributes aren't decoration. A tabs component without correct ARIA roles is a keyboard and screen-reader trap, and it's easy to skip this when you're focused on getting the context wiring right. If you're building this for real, look at how Radix UI's Tabs primitive or React Aria's tabs hook handle keyboard navigation between triggers, since that logic (arrow keys moving focus, roving tabindex) is genuinely fiddly to get right from scratch.

Slots: an alternative to deep nesting

Compound components work well when the pieces have a clear, repeatable relationship, like tabs, accordions, or menus. But sometimes you have a single component with a handful of named regions, and forcing that into parent/child context feels heavier than it needs to be. This is where slots earn their keep.

A slot API says: here is a component with named insertion points, and you can either use the defaults or override them.

interface CardProps {
  header?: ReactNode;
  footer?: ReactNode;
  children: ReactNode;
}

function Card({ header, footer, children }: CardProps) {
  return (
    <div className="card">
      {header && <div className="card-header">{header}</div>}
      <div className="card-body">{children}</div>
      {footer && <div className="card-footer">{footer}</div>}
    </div>
  );
}

This is intentionally simpler than a context-based compound component. There's no shared state between header, body, and footer, so context would be overkill. Props with ReactNode types are the slot mechanism here, and they read clearly at the call site:

<Card
  header={<h3>Invoice #4821</h3>}
  footer={<Button onClick={handleDownload}>Download PDF</Button>}
>
  <InvoiceLineItems items={lineItems} />
</Card>

The tradeoff versus compound components is that slots don't compose as naturally when the number of named regions grows. Five or six ReactNode props on one component starts to feel like the config-object problem again, just with JSX instead of data. When that happens, it's often a sign the component is trying to do too much. Splitting it into smaller pieces, possibly compound components, is the better fix rather than adding a seventh slot prop.

Tradeoffs and when to reach for each pattern

Compound components buy you flexible composition, but they carry three real costs. First, context makes the implicit contract between parent and children harder to see at a glance; a new engineer reading <Tabs.Content value="team"> has to go find Tabs to understand what value means and how state flows. Second, context re-renders every consumer when the value changes, so a Tabs with many TabsContent children re-renders all of them on every tab switch unless you split context values or memoize aggressively. Third, TypeScript can get awkward when sub-components need generics that depend on the parent's generic type; you often end up threading the type through context in ways that feel more complex than the UI problem warrants.

Slots avoid all three of those costs but don't scale past a handful of named regions, and they don't help when siblings need to coordinate behavior (like only one tab being active at a time). The honest rule I use: default to slots for components with static named regions and no shared interactive state. Reach for compound components only when siblings genuinely need to talk to each other: selection state, open/closed state, or registration (like a Select needing to know how many Option children exist for keyboard navigation).

It's tempting to build every reusable component as a compound component because it looks more sophisticated in a design system. Resist that. A Card with a header and footer slot is a better, simpler API than a Card.Header/Card.Body/Card.Footer compound version if there's no coordination logic between them. Adding context for the sake of a consistent-looking API is complexity you didn't earn.

Production pitfalls

Forgetting the context guard is the most common bug. Someone renders Tabs.Trigger outside Tabs, often during a refactor where the wrapper gets accidentally removed, and without the explicit error check, the failure surfaces somewhere unrelated.

Over-splitting context is a subtler issue. If your Tabs context holds both activeValue and unrelated things like orientation or size, every consumer re-renders when any of those values change, even ones that only care about activeValue. Splitting into a state context and a "static config" context (created once, rarely changing) keeps re-renders scoped to what actually changed.

Forwarding refs through compound components is easy to overlook. If Tabs.Trigger wraps a <button> but doesn't forward a ref, consumers who need to imperatively focus a specific trigger (for accessibility fixes, for instance) hit a wall. Use forwardRef on any sub-component that wraps a native interactive element.

Finally, watch for consumers who reorder or omit expected children. A compound Tabs.List that assumes Tabs.Trigger children always exist in the same order as Tabs.Content children will break if someone conditionally renders one trigger but not its matching content. Matching by value rather than by array index, as the example above does, avoids this entirely.

Key takeaways

  • Compound components share implicit state across parent and children via context, letting consumers control composition instead of passing configuration objects.
  • Slots (named `ReactNode` props) are the simpler tool for components with static regions and no shared interactive state; don't reach for context until siblings genuinely need to coordinate.
  • Always guard context consumption with a clear error message when a sub-component is used outside its parent.
  • Split context values by how often they change to avoid re-rendering every sub-component on every state update.
  • Preserve ARIA roles and keyboard behavior; the pattern gives you composition flexibility, not accessibility for free.
  • Match children by an explicit key (like `value`) rather than array position to avoid breakage when consumers reorder or conditionally render pieces.

Frequently asked questions

What's the difference between compound components and render props?

Render props pass a function as a child or prop so the consumer controls what gets rendered with access to internal state. Compound components instead expose multiple named sub-components that implicitly share state through context. Render props tend to result in more nesting and less readable JSX at the call site; compound components read more like plain markup once set up, at the cost of a less visible contract.

Do I need a UI library like Radix UI or Headless UI to use this pattern?

No, the pattern is plain React (context plus composition), and the example in this article has no external dependencies. Libraries like Radix UI and React Aria are worth adopting when you need production-grade accessibility (focus management, keyboard navigation, roving tabindex) that's genuinely tedious to reimplement correctly, not because compound components require them.

How do compound components affect bundle size?

Negligibly. The pattern itself is just a `createContext` call and a handful of small functions; there's no runtime overhead beyond normal context updates. Bundle size concerns come from the sub-components' own dependencies (icon libraries, animation libraries), not from the compound pattern itself.

Can I use compound components with Next.js Server Components?

Context requires a Client Component boundary, since `createContext` and `useContext` only work in client-rendered trees. In practice this means the compound component itself (`Tabs`, `Tabs.List`, `Tabs.Trigger`, `Tabs.Content`) needs a `"use client"` directive, but the content passed as children can still be Server Components if they don't depend on the tab's interactive state.

How do I type sub-components so TypeScript enforces that a `value` prop matches between `Tabs.Trigger` and `Tabs.Content`?

Plain TypeScript can't statically enforce that two independent components share the same literal union of `value` strings without extra tooling. In practice, most teams accept a runtime mismatch as an edge case caught in code review or a dev-mode warning (comparing registered trigger values to content values), rather than trying to force it through generics, since the type-level solution usually costs more complexity than the bug is worth.

When should I refactor a slot-based component into a compound component?

When you notice the slot props starting to reference each other's state, for example a `footer` slot needing to know whether the `header`'s dropdown is open. That's the signal that the pieces have started coordinating, and context will express that more cleanly than threading extra props through.

Related articles

Dark Mode Done Properly — Aman Kumar Singh
Building a UI with shadcn/ui — Aman Kumar Singh
React Performance Optimization — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.