Latest Post: Compound Components Pattern: React and Svelte Examples

Compound Components Pattern: React and Svelte Examples

Discover the Compound Components pattern (also known as Headless UI, Components as API, or Slot-Based API in Svelte). Learn its advantages with practical React and Svelte 5 examples.

Manuel Sanchez

8 min read
Example of a Card component with a header, body and footer

When designing UI libraries or design systems, developers often face the problem of prop soup, that is components with endless props. They become hard to read, rigid, and bloated.

A cleaner approach is the Compound Components Pattern, as we will see through this article.

Why Compound Components?

First, I want to show you what we’re trying to avoid. Imagine we need to create a Card component. Even though it might look simple at first, it can quickly become bloated with props. Let’s say we’re working in the React ecosystem, though the same idea also applies to Svelte.

// Example of a Card component in React with prop soup
<Card
  title="This is the infamous prop soup card..."
  subtitle="Next milestone: Beta"
  body="This is the body of the card. It can be very long, so we need to make sure it looks good."
  footerButtons={[
    { label: 'Details', onClick: () => {} },
    { label: 'Launch', onClick: () => {} },
  ]}
  variant="elevated"
  footerAlign="between"
/>

Hell is also paved with good intentions.

At this point, you might be thinking: “Come on, Manuel, I’ve seen worse!” True, no doubt about that. But we can still find ways to avoid it.

Otherwise, adding just a couple of new features to the Card component, such as an image or a badge, would mean introducing even more props, making it more complex and harder to read.

Another approach could be to create multiple components, such as CardWithImage, CardWithBadge, and so on. But this quickly leads to a combinatorial explosion of components, which makes things harder to maintain and use. (Still better than prop soup, though.)

The Compound Components Pattern

The Compound Components Pattern (also known as Components as API, or Slot-Based API in Svelte 4) is a design approach that makes components more flexible and reusable by breaking them down into smaller, more manageable pieces.

If you want to see exactly how to implement this pattern in Svelte 4, Svelte 5 and React, check out the following GitHub repository:

Repository with examples in Svelte 4, Svelte 5 and React

All the work we saw with props can be split into smaller components that we compose together. This way, we can build a Card component that’s easier to read, more flexible, and highly reusable. Here’s an example of how it might look using Svelte 4 syntax:

// Example of usage of the Card component in Svelte 4
<Card.Root variant="elevated">
  <Card.Header
    slot="header"
    title="This is a compound card in Svelte 4"
    subtitle="Next milestone: Beta"
  />
  <Card.Body>
    <p>
      Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolore
      repellendus, labore natus, quod numquam consequuntur ad praesentium,
      distinctio iste at inventore eos? Nulla eveniet cum nemo pariatur quam
      magni maiores.
    </p>
  </Card.Body>
  <Card.Footer separated={false} slot="footer" align="between">
    <span class="muted small">Updated 2h ago</span>
    <div style="display:flex; gap:.5rem">
      <button class="btn">Details</button>
      <button class="btn primary">Launch</button>
    </div>
  </Card.Footer>
</Card.Root>

I really like this example because it clearly shows how the card can be divided into three main parts: header, body, and footer. In this approach, I wanted to combine elements that receive props with plain text (see the Card.Header component), along with elements that act more like free-form wrappers (see the Card.Body and Card.Footer components).

With this structure:

  • Card.Root handles box shadows, elevation, and size.

  • Card.Header manages the title, subtitle, and whether a bottom border should be rendered (separation boolean). Padding is predefined.

  • Card.Body is the most flexible, with just padding applied.

  • Card.Footer is also flexible, supporting alignment properties and a separation option for a top border.

You could also expand this with variants, letting Card.Root toggle the whole component by switching a couple of CSS variables. Or, you can pass classes directly to the specific part you want (looking at you, Tailwind friends 👋). If you’re not using Tailwind, you can still pass custom classes and apply them globally, but I try to avoid that when possible.

Overall, this approach strikes a nice balance between flexibility and ease of use. In my opinion, once you go beyond four props in a single component, you’re heading straight into prop soup territory.

And by the way, the reason this setup works so smoothly is that everything is exported through a barrel file like this:

// index.ts (Card component)
export { default as Root } from './Root.svelte'
export { default as Header } from './Header.svelte'
export { default as Body } from './Body.svelte'
export { default as Footer } from './Footer.svelte'

The issue with this approach is that slots are deprecated in Svelte 5, so we need to look for an alternative. And no—much as I enjoy using snippets in Svelte 5—they’re not the right solution here.

A solid alternative is the one used by shadcn-svelte, where every component is exported separately. The main component acts as a simple wrapper, and each subcomponent is also a wrapper that renders its children inside.

In the repo I created, I started with that approach and extended it a bit to support more variants.

// Example of usage of the Card component in Svelte 5
<Card.Root aria-labelledby="sv5-header" variant="elevated" size="large">
  <Card.Header>
    <Card.Title id="sv5-header">This is a compound card in Svelte 5</Card.Title>
  </Card.Header>
  <Card.Content>
    <Card.Description>
      Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolore
      repellendus, labore natus, quod numquam consequuntur ad praesentium,
      distinctio iste at inventore eos? Nulla eveniet cum nemo pariatur quam
      magni maiores.
    </Card.Description>
    <form style="margin-top:12px; display:flex; flex-direction:column; gap:8px">
      <input
        type="email"
        placeholder="Email"
        class="input"
        style="width:100%; box-sizing:border-box"
      />
      <input
        type="password"
        placeholder="Password"
        class="input"
        style="width:100%; box-sizing:border-box"
      />
    </form>
  </Card.Content>
  <Card.Footer>
    <button class="btn">Cancel</button>
    <button class="btn primary">Login</button>
  </Card.Footer>
</Card.Root>

See an example of how any of this wrapper components looks like:

// card.svelte (Card Root component, Svelte 5)
<script lang="ts">
  import type { WithElementRef } from "$lib/utils"
  import type { HTMLAttributes } from "svelte/elements"

  type Props = WithElementRef<HTMLAttributes<HTMLElement>> & {
    size?: "small" | "medium" | "large" | "container"
    variant?: "default" | "elevated" | "outline"
  }

  let {
    size = "container",
    variant,
    ref = $bindable(null),
    class: className = "",
    children,
    ...restProps
  }: Props = $props()
</script>

<article
  bind:this={ref}
  data-slot="card"
  class={`bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm ${size} ${className}`}
  {...restProps}
>
  {@render children?.()}
</article>

<style>
  :root {
    --card-size-sm: 300px;
    --card-size-md: 450px;
    --card-size-lg: 600px;
    --card-size-container: 100%;
  }

  [data-slot="card"] {
    background: #fff;
    border: 1px solid var(--border);
    border-radius: 16px;
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
    outline: none;
    height: fit-content;
    width: 100%;
  }

  [data-slot="card"].elevated {
    box-shadow: 0 10px 15px rgba(0, 0, 0, 0.08);
  }
  [data-slot="card"].outline {
    border-width: 2px;
  }

  [data-slot="card"].small {
    max-width: var(--card-size-sm);
  }
  [data-slot="card"].medium {
    max-width: var(--card-size-md);
  }
  [data-slot="card"].large {
    max-width: var(--card-size-lg);
  }

  [data-slot="card"].container {
    max-width: var(--card-size-container);
  }
</style>

I got also these methods from shadcn that I find very useful for this pattern:

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any }
  ? Omit<T, 'children'>
  : T
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
  ref?: U | null
}

You might be wondering why we need the ref prop in all the components. The reason is that it gives us control over the component’s DOM element and provides flexibility for advanced use cases, such as animations or integrations with third-party libraries. It’s optional, but highly recommended.

Compound Components Pattern in React?

If we wanted to do this in React, we could start simple with something like this:

// Card.tsx
import * as React from 'react'

function CardRoot({ children, ...props }) {
  return <article {...props}>{children}</article>
}

function CardHeader({ children }) {
  return <div className="card-header">{children}</div>
}

function CardBody({ children }) {
  return <div className="card-body">{children}</div>
}

function CardFooter({ children }) {
  return <div className="card-footer">{children}</div>
}

export const Card = Object.assign(CardRoot, {
  Header: CardHeader,
  Body: CardBody,
  Footer: CardFooter,
})

One advantage of React over Svelte is that we can keep everything in the same file and export it as a single component, instead of splitting it across multiple files. But the core idea remains the same.

Later on, we could add context to share state between the components, for example, to auto-wire aria-labelledby, roles, or IDs. And to have references to the DOM elements, we could use React.forwardRef (until React 18). Later on, with React 19, we will have to pass refs as props to make it work. See all that in the repo.

Conclusion

Compound components make your UI code more declarative, accessible, and developer-friendly.

Whether you’re working in React 18, React 19 or Svelte 4 or Svelte 5, the idea is the same: break down complex components into composable building blocks that can be assembled naturally.

We will:

  • Write less code (no endless prop drilling).

  • Deliver more flexibility to teams consuming your components.

  • Ensure accessibility is built-in, not bolted on.

  • Keep your markup semantic and expressive.

Your future self (and your team) will thank you.


FAQ about Compound Components Pattern

Compound components are a design pattern that allows you to create flexible and reusable components by breaking them down into smaller, more manageable pieces.


Share article

Related Posts

Stay in the loop!

Get to know some good resources. Once per month.

Frontend & Game Development, tools that make my life easier, newest blog posts and resources, codepens or some snippets. All for free!

No spam, just cool stuff. Promised. Unsubscribe anytime.