The compound components pattern helps you build components that work together like a team. It makes your UI easier to use and customize by grouping related parts inside one main component.
Compound components pattern in Vue
<script setup> import { provide, inject, ref } from 'vue' const TabContext = Symbol('TabContext') function useTabs() { const activeTab = ref(null) function setActiveTab(name) { activeTab.value = name } provide(TabContext, { activeTab, setActiveTab }) } function useTab() { const context = inject(TabContext) if (!context) throw new Error('useTab must be used inside Tabs') return context } </script>
Use provide and inject to share state between compound components.
Each subcomponent accesses shared state via inject to stay connected.
<template>
<Tabs>
<TabList>
<Tab name="home">Home</Tab>
<Tab name="profile">Profile</Tab>
</TabList>
<TabPanels>
<TabPanel name="home">Welcome Home!</TabPanel>
<TabPanel name="profile">User Profile Info</TabPanel>
</TabPanels>
</Tabs>
</template><script setup> import { ref, provide, inject } from 'vue' const TabsSymbol = Symbol() const Tabs = { setup(_, { slots }) { const active = ref(null) function setActive(name) { active.value = name } provide(TabsSymbol, { active, setActive }) return () => slots.default() } } const Tab = { props: ['name'], setup(props, { slots }) { const { active, setActive } = inject(TabsSymbol) const isActive = () => active.value === props.name return () => ( <button aria-selected={isActive()} onClick={() => setActive(props.name)} > {slots.default()} </button> ) } } </script>
This complete example creates a tabs interface using the compound components pattern. Clicking a tab button changes the active tab and shows the matching content panel.
<script setup> import { ref, provide, inject, defineComponent, h } from 'vue' const TabsSymbol = Symbol('Tabs') const Tabs = defineComponent({ setup(_, { slots }) { const activeTab = ref(null) function setActiveTab(name) { activeTab.value = name } provide(TabsSymbol, { activeTab, setActiveTab }) return () => h('div', { role: 'tablist' }, slots.default()) } }) const Tab = defineComponent({ props: { name: String }, setup(props, { slots }) { const context = inject(TabsSymbol) if (!context) throw new Error('Tab must be used inside Tabs') const isActive = () => context.activeTab.value === props.name function onClick() { context.setActiveTab(props.name) } return () => h('button', { role: 'tab', 'aria-selected': isActive(), onClick }, slots.default()) } }) const TabPanel = defineComponent({ props: { name: String }, setup(props, { slots }) { const context = inject(TabsSymbol) if (!context) throw new Error('TabPanel must be used inside Tabs') const isVisible = () => context.activeTab.value === props.name return () => isVisible() ? h('div', { role: 'tabpanel' }, slots.default()) : null } }) </script> <template> <Tabs> <Tab name="first">First Tab</Tab> <Tab name="second">Second Tab</Tab> <TabPanel name="first">Content for the first tab.</TabPanel> <TabPanel name="second">Content for the second tab.</TabPanel> </Tabs> </template>
Make sure all compound components are used inside the main parent component to access shared state.
Use semantic roles like tablist, tab, and tabpanel for accessibility.
Compound components keep your UI flexible and easy to maintain by separating concerns.
The compound components pattern groups related UI parts inside one main component.
Use provide and inject in Vue to share state between these parts.
This pattern helps build flexible, accessible, and easy-to-use components like tabs or dropdowns.