Hero image for "The Composition Pattern That Actually Survives Contact With a Real Product"

The Composition Pattern That Actually Survives Contact With a Real Product


Most component APIs are designed for the demo, not the product. They look clean in Storybook, hold together in the docs, and then quietly fall apart the moment a second product surface needs the same component to behave slightly differently.

The pattern that survives this is compound components — and it's worth being precise about why, because the usual explanation ("it's more flexible") is too vague to be useful.

The Problem Isn't Flexibility, It's Coupling

Here's what actually happens. You build a Card component for your dashboard. It has a title, a body, an optional footer. You prop it out: title, body, footerContent. Six months later, marketing needs a card with a badge in the title area. You add a titleBadge prop. Then someone needs two action buttons in the footer. You add footerActions. Then the mobile surface needs the footer suppressed entirely on small viewports, but only sometimes.

You now have a component with eight props, three of which are mutually exclusive, and a README comment that says "don't use footerContent and footerActions together."

This is prop explosion — and it's not a styling problem, it's a coupling problem. The component is trying to own the layout decisions of every surface that uses it. That's the wrong contract.

What Compound Components Actually Buy You

The compound component pattern inverts this. Instead of one component that accepts everything, you expose a set of composable primitives that share implicit state:

<Card>
  <Card.Header>
    <Card.Title>Revenue</Card.Title>
    <Badge variant="up">+12%</Badge>
  </Card.Header>
  <Card.Body>
    <RevenueChart />
  </Card.Body>
  <Card.Footer>
    <Button size="sm">View report</Button>
  </Card.Footer>
</Card>

The Card manages shared context — border radius, shadow, padding rhythm, focus behavior. The sub-components slot into that context without needing to know about each other. The badge in the header is just a Badge. The button in the footer is just a Button. The Card component itself has maybe three props.

When It Hurts

Compound components have a real cost: they shift cognitive load to the consumer. A prop-based API is self-documenting in a way that a composition API isn't. <Card title="Revenue" footerContent={<Button>View</Button>} /> is immediately readable. The compound version requires knowing which sub-components exist and how they nest.

For a team of two shipping fast, that documentation burden matters. If your design system has one primary consumer (your own product), and that product has low surface variance, the prop-based API is probably fine. The overhead of compound components pays off when you have multiple product surfaces with genuinely different layout needs — a dashboard, a marketing site, a mobile app — all pulling from the same component library.

I'd argue the inflection point is roughly when you find yourself adding a prop to handle a layout exception for a specific surface. That's the signal. One exception, maybe you add the prop. Two exceptions, you're building a configuration API. Three, you've already lost — refactor to composition.

The Token Layer Underneath

Composition patterns only scale if the token layer underneath them is stable. A compound Card that references --color-primary-500 directly is still tightly coupled — just to the token name instead of a prop. The pattern that survives, as Tyler McDaniel documents from building three token systems, is three tiers: primitive values, semantic aliases, and component-scoped tokens.

The compound Card.Header shouldn't know about --blue-600. It should reference --card-header-background, which maps to --surface-secondary, which maps to the primitive. That indirection is what lets you theme across surfaces without touching component code.

Without that separation, composition patterns give you structural flexibility but not visual flexibility. You can rearrange the pieces; you can't reskin them.

The Heuristic

Before adding a prop to handle a layout or content exception, ask: is this exception specific to one surface, or is it a genuine variant that belongs in the component contract?

If it's surface-specific, push the decision up to the consumer via composition. If it's a true variant — a Card that is semantically different from a default Card — make it a named variant with its own token scope.

The goal isn't maximum flexibility. It's keeping the component's API surface small enough that a new engineer can use it correctly without reading the source.