Skip to content
← All questions
🔥 Advanced

What is the compound components pattern in Vue?

🏗️ Architecture🧩 Components⚡ Vite💉 Provide/Inject

Compound components are a set of components that work together and share implicit state through provide/inject. The parent manages the state, the children consume it. The consumer arranges the children freely without wiring props or events between them manually.

The classic example is an accordion, tabs, or select component where the parent tracks which item is active and the children register themselves.

Tree diagram showing parent providing context and children consuming via inject

Example: Tabs

vue
<!-- components/Tabs.vue -->
<script setup lang="ts">
const activeTab = ref(0)
const tabs = ref<string[]>([])

function register(label: string) {
  tabs.value.push(label)
  return tabs.value.length - 1
}

function select(index: number) {
  activeTab.value = index
}

provide('tabs', {
  activeTab: readonly(activeTab),
  register,
  select
})
</script>

<template>
  <div class="tabs">
    <div class="tabs-header">
      <button
        v-for="(label, i) in tabs"
        :key="i"
        :class="{ active: activeTab === i }"
        @click="select(i)"
      >
        {{ label }}
      </button>
    </div>
    <div class="tabs-body">
      <slot />
    </div>
  </div>
</template>
Open in Vue Playground
vue
<!-- components/Tab.vue -->
<script setup lang="ts">
const props = defineProps<{ label: string }>()

const { activeTab, register } = inject('tabs')!
const index = register(props.label)

const isActive = computed(() => activeTab.value === index)
</script>

<template>
  <div v-show="isActive">
    <slot />
  </div>
</template>
Open in Vue Playground

Usage

vue
<template>
  <Tabs>
    <Tab label="Profile">
      <UserProfile />
    </Tab>
    <Tab label="Settings">
      <UserSettings />
    </Tab>
    <Tab label="Billing">
      <BillingInfo />
    </Tab>
  </Tabs>
</template>
Open in Vue Playground

The consumer doesn't manage active state, doesn't wire click handlers, doesn't pass indices. The Tabs and Tab components coordinate internally through provide/inject.

Type-safe provide/inject

Use an InjectionKey to avoid string keys and get full typing:

ts
// composables/useTabs.ts
import type { InjectionKey, Ref } from 'vue'

interface TabsContext {
  activeTab: Readonly<Ref<number>>
  register: (label: string) => number
  select: (index: number) => void
}

export const TABS_KEY: InjectionKey<TabsContext> = Symbol('tabs')
vue
<!-- Tabs.vue -->
<script setup>
import { TABS_KEY } from '@/composables/useTabs'
provide(TABS_KEY, { activeTab: readonly(activeTab), register, select })
</script>
Open in Vue Playground
vue
<!-- Tab.vue -->
<script setup>
import { TABS_KEY } from '@/composables/useTabs'
const ctx = inject(TABS_KEY)
if (!ctx) throw new Error('Tab must be used inside Tabs')
</script>
Open in Vue Playground

Example: Accordion

vue
<!-- components/Accordion.vue -->
<script setup lang="ts">
const openItems = ref<Set<string>>(new Set())

function toggle(id: string) {
  if (openItems.value.has(id)) {
    openItems.value.delete(id)
  } else {
    openItems.value.add(id)
  }
}

function isOpen(id: string) {
  return openItems.value.has(id)
}

provide('accordion', { toggle, isOpen })
</script>

<template>
  <div class="accordion">
    <slot />
  </div>
</template>
Open in Vue Playground
vue
<!-- components/AccordionItem.vue -->
<script setup lang="ts">
const props = defineProps<{ id: string; title: string }>()
const { toggle, isOpen } = inject('accordion')!
</script>

<template>
  <div class="accordion-item">
    <button @click="toggle(id)">
      {{ title }}
      <span>{{ isOpen(id) ? '−' : '+' }}</span>
    </button>
    <div v-show="isOpen(id)">
      <slot />
    </div>
  </div>
</template>
Open in Vue Playground
vue
<template>
  <Accordion>
    <AccordionItem id="faq-1" title="What is Vue?">
      <p>A progressive JavaScript framework.</p>
    </AccordionItem>
    <AccordionItem id="faq-2" title="What is Vite?">
      <p>A build tool for modern web projects.</p>
    </AccordionItem>
  </Accordion>
</template>
Open in Vue Playground

When to use compound components

SituationUse compound components?
Group of UI elements that share state (tabs, accordion, select, stepper)Yes
Components always used together in a parent-child relationshipYes
Independent components that occasionally interactNo, use props/emits or a store
Simple parent-child with 1-2 propsNo, over-engineering

Compound components vs props-only approach

The props-only approach passes all data to a single component:

vue
<!-- Props-only: less flexible, simpler for small cases -->
<Tabs
  :items="[
    { label: 'Profile', content: '...' },
    { label: 'Settings', content: '...' }
  ]"
/>

<!-- Compound: more flexible, consumer controls layout -->
<Tabs>
  <Tab label="Profile"><UserProfile /></Tab>
  <Tab label="Settings"><UserSettings /></Tab>
</Tabs>
Open in Vue Playground

Compound components win when the children have complex content (components, slots, conditional logic) that doesn't fit cleanly in a data array.

See also: How does provide/inject work? · What are slots? · What is the container-presentational pattern?

References

Released under the MIT License.