Skip to content
← All questions
Advanced

What is the compound components pattern in Vue?

ArchitectureComponents

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.

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>
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>

Usage

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

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>
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>

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>
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>
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>

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>

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

Released under the MIT License.