Cleanly separating logic and presentation in Vue.js
A component that manages logic and renders HTML at the same time is barely reusable. Renderless Components and Headless Patterns in Vue.js solve this problem: the logic lives in a composable or a renderless component, and the consumer takes full ownership of the presentation via a scoped slot or template.
Table of Contents
- 1. What Renderless Components achieve in Vue
- 2. The renderless pattern: a component with no template of its own
- 3. Headless UI: from component to concept
- 4. Composable vs. Renderless Component: which pattern when?
- 5. Scoped slots as a communication channel
- 6. provide/inject for deeply nested headless trees
- 7. Headless in design systems: styling freedom without code duplication
- 8. Testing renderless components
- 9. Renderless vs. Headless vs. the classic component pattern
- 10. Summary
- 11. FAQ
1. What Renderless Components achieve in Vue
The central problem in growing Vue applications is the coupling of logic and presentation inside the same component. An autocomplete component contains the search logic, keyboard navigation, opening and closing the dropdown, and at the same time the specific HTML markup with its design classes. That makes the component hard to reuse: anyone who needs the same autocomplete behavior with different markup, in another design system, in a different color, as a mobile-optimized variant, has to duplicate the entire component or transform it into an opaque mountain of props.
Renderless Components in Vue solve this problem through radical separation: a renderless component renders no HTML of its own but exposes logic, state, and actions via a scoped slot. The consumer decides the markup entirely. This is not an academic concept but the approach behind headless UI libraries like Radix UI, Headless UI, or Vue's own VueUse. The logic lives once, the presentations can be arbitrarily many.
2. The renderless pattern: a component with no template of its own
A renderless component in Vue has a template consisting solely of a <slot>, or in a functional implementation just a render function that calls the default slot with state data. All the logic lives in the setup function. State, computed properties, and event handlers are exposed as slot props so the consumer can use them in its own template. That turns the renderless component into a pure behavior provider with no opinion about presentation.
The pattern is especially elegant in Vue 3 with the Composition API, because setup already returns all reactive data anyway. A renderless component is conceptually almost identical to a composable, the difference lies in the context: the composable is imported inside the consumer component's setup function, while the renderless component shows up as a tag inside the consumer's template and shares its state via slot props. Both patterns have their place, depending on whether template composition or code composition is preferred.
<!-- RenderlessAutocomplete.vue -- Pure logic, no markup -->
<template>
<!-- All state and actions exposed via scoped slot -->
<slot
:query="query"
:results="results"
:isOpen="isOpen"
:isLoading="isLoading"
:activeIndex="activeIndex"
:onInput="handleInput"
:onKeydown="handleKeydown"
:selectItem="selectItem"
:close="close"
/>
</template>
<script setup>
// Renderless component: only logic, no visual opinion
import { ref, watch } from 'vue'
const props = defineProps({
modelValue: String,
fetchFn: { type: Function, required: true },
debounce: { type: Number, default: 300 },
})
const emit = defineEmits(['update:modelValue', 'select'])
const query = ref(props.modelValue ?? '')
const results = ref([])
const isOpen = ref(false)
const isLoading = ref(false)
const activeIndex = ref(-1)
let debounceTimer = null
const fetchResults = async (val) => {
if (!val.trim()) { results.value = []; isOpen.value = false; return }
isLoading.value = true
try {
results.value = await props.fetchFn(val)
isOpen.value = results.value.length > 0
} finally { isLoading.value = false }
}
const handleInput = (e) => {
query.value = e.target.value
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => fetchResults(query.value), props.debounce)
}
const handleKeydown = (e) => {
if (e.key === 'ArrowDown') activeIndex.value = Math.min(activeIndex.value + 1, results.value.length - 1)
if (e.key === 'ArrowUp') activeIndex.value = Math.max(activeIndex.value - 1, 0)
if (e.key === 'Enter' && activeIndex.value >= 0) selectItem(results.value[activeIndex.value])
if (e.key === 'Escape') close()
}
const selectItem = (item) => { emit('select', item); emit('update:modelValue', item.label); close() }
const close = () => { isOpen.value = false; activeIndex.value = -1 }
</script>
3. Headless UI: from component to concept
Headless Patterns in Vue go a step further than individual renderless components: they describe an entire concept of components that supply only behavior and accessibility, never styling. The idea stems from the observation that accessibility, correct ARIA attributes, keyboard navigation, focus management, is identical for any kind of dropdown, modal, accordion, or combobox, regardless of how it looks. Implementing this logic correctly once and then reusing it for arbitrarily many visual variants is the goal of the headless approach.
In Vue this means: a headless disclosure component knows when a region is open or closed, sets the correct aria-expanded and aria-controls attributes, handles keyboard events, and offers one slot for the trigger and one for the content. What the trigger looks like visually, a button, a link, an icon, is decided entirely by the consumer. The headless component only guarantees that it works correctly and accessibly.
4. Composable vs. Renderless Component: which pattern when?
The question of when to use a composable and when a renderless component in Vue is a question of usage context. Composables are better suited when the logic should be wired into existing components without changing the template. A useAutocomplete composable can be imported into any existing component that already has the right HTML markup. The renderless component is better suited when the entire component hierarchy needs to stay flexible and the consumer builds its own template from scratch.
A practical criterion: when the logic controls several related elements, for example a trigger button and a content panel as in an accordion, the renderless component is more elegant, because it can coordinate both parts via slot props. When the logic only involves a reactive state and a couple of methods, for example a toggle function, a composable is entirely sufficient. In modern Vue 3 projects the trend is clear: composables for pure logic, renderless components when template structure needs to be coordinated.
// composables/useDisclosure.js -- Pure logic for open/close state
import { ref, computed } from 'vue'
export function useDisclosure(initialState = false) {
const isOpen = ref(initialState)
// Aria attributes computed -- consumer just spreads them onto elements
const triggerAttrs = computed(() => ({
'aria-expanded': isOpen.value,
'aria-controls': 'disclosure-panel',
}))
const panelAttrs = computed(() => ({
id: 'disclosure-panel',
'aria-hidden': !isOpen.value,
}))
const open = () => { isOpen.value = true }
const close = () => { isOpen.value = false }
const toggle = () => { isOpen.value = !isOpen.value }
return { isOpen, open, close, toggle, triggerAttrs, panelAttrs }
}
// Usage in a component -- full control over markup
// const { isOpen, toggle, triggerAttrs, panelAttrs } = useDisclosure()
// <button v-bind="triggerAttrs" @click="toggle">Toggle</button>
// <div v-bind="panelAttrs" v-show="isOpen"><slot /></div>
5. Scoped slots as a communication channel
Scoped slots are the backbone of the renderless pattern in Vue. They let a child component inject data into the slot content of the parent, the reverse of the normal data flow chain in Vue. The renderless component binds reactive state values and methods to the slot, the consumer receives them as template variables and can use them freely. That is more powerful than props, because it grants full control over the markup without forcing a fixed component API.
The pattern is not infinitely scalable, though. When a renderless component exposes more than ten slot props, the API becomes unwieldy. Grouping helps here: instead of ten individual props, expose a state object slot and an actions object slot. v-slot="{ state, actions }" is more readable than v-slot="{ isOpen, query, activeIndex, results, onInput, onKeydown, selectItem, close, reset, isLoading }". The same principle applies to TypeScript typing: slot props should be defined as an interface that the consumer can import and use.
6. provide/inject for deeply nested headless trees
Scoped slots work well when the renderless component directly controls the slot consumers. In deeply nested component trees, for example a headless tabs component that manages tabs and panels in arbitrarily deep child hierarchies, scoped slots become impractical. This is where the provide/inject pattern in Vue comes in, designed for exactly this use case: a parent headless component exposes a shared state via provide, and every child component in the hierarchy accesses it via inject.
The provide/inject pattern is type-safe with an InjectionKey symbol from TypeScript: the parent headless component exports the key, child components import it and get full TypeScript autocompletion on the injected state. Combined with readonly wrapping of the provided state, child components can read the state but can only trigger mutations via exposed methods, this results in a clean unidirectional data flow pattern even in complex headless hierarchies.
7. Headless in design systems: styling freedom without code duplication
The biggest practical advantage of Headless Patterns in Vue shows up in design systems with multiple visual variants. Instead of maintaining a separate component with duplicated logic for every variant, dark mode, mobile layout, a compact dashboard layout, there is a single headless base component and as many presentation components on top of it as needed. This means: if a bug is found in an accordion's keyboard navigation, it gets fixed once in the headless base component and every variant benefits immediately.
Libraries like Radix Vue or Headless UI for Vue implement this principle consistently. In your own design systems you follow the same pattern: headless components in their own package or directory, presentation components as separate wrappers that define only classes and markup. The headless layer is stable and tested, the presentation layer is lightweight and easy to swap out. That is the foundation for maintainable, scalable Vue component libraries.
<!-- HeadlessTabs.vue -- Logic and ARIA, no visual opinion -->
<template>
<!-- Provide shared state to all child Tab and Panel components -->
<slot :activeTab="activeTab" :setTab="setTab" />
</template>
<script setup>
import { ref, provide } from 'vue'
import { TABS_INJECTION_KEY } from './tabs-injection-key'
const props = defineProps({ defaultTab: { type: String, required: true } })
const activeTab = ref(props.defaultTab)
const setTab = (id) => { activeTab.value = id }
// Provide state to all descendant Tab/Panel components
provide(TABS_INJECTION_KEY, { activeTab, setTab })
</script>
<!-- DesignSystemTabs.vue -- Visual wrapper using headless core -->
<template>
<HeadlessTabs :defaultTab="defaultTab">
<template #default="{ activeTab, setTab }">
<div class="border-b border-slate-200 flex gap-0">
<button
v-for="tab in tabs"
:key="tab.id"
:aria-selected="activeTab === tab.id"
:class="['px-4 py-2 text-sm font-medium border-b-2 transition-colors',
activeTab === tab.id
? 'border-green-600 text-green-700'
: 'border-transparent text-slate-500 hover:text-slate-700']"
@click="setTab(tab.id)"
>{{ tab.label }}</button>
</div>
<div class="mt-4">
<slot :activeTab="activeTab" />
</div>
</template>
</HeadlessTabs>
</template>
8. Testing renderless components
One of the biggest advantages of Renderless Components in Vue is testability. Since a renderless component renders no markup, tests can focus entirely on the logic without worrying about CSS classes or visual details. With Vue Test Utils you mount the renderless component with a test slot that captures the slot props into local variables. Then you simulate actions and check the state, fully decoupled from visual rendering.
The second advantage: when all the logic lives in a composable, the composable can be tested directly with withSetup or the VueUse test pattern, without mounting a component at all. That makes tests faster and less fragile. Renderless Components and Headless Patterns are therefore not just an architectural decision for maintainability but are directly tied to the quality of test coverage, more logic in composables means simpler, more stable tests.
9. Renderless vs. Headless vs. the classic component pattern
| Criterion | Classic | Renderless | Headless |
|---|---|---|---|
| Markup control | Only via props | Fully with the consumer | Fully with the consumer |
| Reusability | Low | High | Very high |
| Testability | Medium | High | Very high |
| Entry barrier | Low | Medium | Higher |
| Styling flexibility | Limited | Full | Full |
| A11y guarantee | Manual | Manual | Built in |
Classic components are the right starting point for simple, one-off UI building blocks. Renderless components pay off as soon as the same logic is needed in several visual variants. Headless patterns are the choice for component libraries and design systems where accessibility and full styling control are needed at the same time. The three patterns are not mutually exclusive, a well-structured Vue project uses all three depending on complexity and reuse needs.
Mironsoft
Vue.js component architecture, design systems, and composable development
Need a Vue component library with maximum reusability?
We build Renderless Components and Headless Patterns that cleanly separate logic and presentation, for maintainable Vue design systems with full styling control and an A11y guarantee.
Architecture review
Analysis of existing components for logic/UI coupling and optimization potential
Headless development
Accessible headless base components for accordion, tabs, dropdown, combobox
Design system integration
Presentation layer as a thin wrapper over a headless core, stable and swappable
10. Summary
Renderless Components and Headless Patterns in Vue.js solve the fundamental problem of logic/UI coupling. A renderless component renders no markup of its own but provides the full state and all actions via a scoped slot. The consumer decides the appearance entirely. Headless patterns extend this approach to entire component hierarchies with built-in accessibility. Composables round out the picture: for pure logic without template coordination, they are the lightest-weight option.
The dividing line between the patterns is clear: composable for isolated logic, renderless component when slot coordination is needed, headless hierarchy when entire component trees need to be coordinated. In design systems, headless patterns are the key to maintainable libraries, a bug in keyboard navigation gets fixed once and affects every visual variant. Testability is a direct side effect: logic in composables and renderless components can be fully covered without any visual tests.
Renderless & Headless in Vue: the essentials at a glance
Renderless Component
No markup of its own. State and actions via scoped slot. Consumer controls the template completely.
Headless Pattern
Behavior and A11y built in, no styling. As many presentation layers as needed over a headless core.
Composable
Pure logic, no template. Lightweight, integrates directly into setup(). Best testability.
provide/inject
For deeply nested headless trees. InjectionKey for TypeScript type safety. Readonly wrapping prevents direct mutations.