.agents/skills/vue-best-practices/references/component-slots.md
Impact: MEDIUM - Slots are a core component API surface in Vue. Structure them intentionally so templates stay predictable, typed, and performant.
# instead of v-slot:)$slots checks)defineSlots in TypeScript componentsBAD:
<MyComponent>
<template v-slot:header> ... </template>
</MyComponent>
GOOD:
<MyComponent>
<template #header> ... </template>
</MyComponent>
Use $slots checks when wrapper elements add spacing, borders, or layout constraints.
BAD:
<!-- Card.vue -->
<template>
<article class="card">
<header class="card-header">
<slot name="header" />
</header>
<section class="card-body">
<slot />
</section>
<footer class="card-footer">
<slot name="footer" />
</footer>
</article>
</template>
GOOD:
<!-- Card.vue -->
<template>
<article class="card">
<header v-if="$slots.header" class="card-header">
<slot name="header" />
</header>
<section v-if="$slots.default" class="card-body">
<slot />
</section>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</footer>
</article>
</template>
In <script setup lang="ts">, use defineSlots so slot consumers get autocomplete and static checks.
BAD:
<!-- ProductList.vue -->
<script setup lang="ts">
interface Product {
id: number
name: string
}
defineProps<{ products: Product[] }>()
</script>
<template>
<ul>
<li v-for="(product, index) in products" :key="product.id">
<slot :product="product" :index="index" />
</li>
</ul>
</template>
GOOD:
<!-- ProductList.vue -->
<script setup lang="ts">
interface Product {
id: number
name: string
}
defineProps<{ products: Product[] }>()
defineSlots<{
default: (props: { product: Product, index: number }) => any
empty: () => any
}>()
</script>
<template>
<ul v-if="products.length">
<li v-for="(product, index) in products" :key="product.id">
<slot :product="product" :index="index" />
</li>
</ul>
<slot v-else name="empty" />
</template>
Fallback content makes components resilient when parents omit optional slots.
BAD:
<!-- SubmitButton.vue -->
<template>
<button type="submit" class="btn-primary">
<slot />
</button>
</template>
GOOD:
<!-- SubmitButton.vue -->
<template>
<button type="submit" class="btn-primary">
<slot>Submit</slot>
</button>
</template>
Renderless components are still useful for slot-driven composition, but composables are usually cleaner for logic-only reuse.
BAD:
<!-- MouseTracker.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const x = ref(0)
const y = ref(0)
function onMove(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
</script>
<template>
<slot :x="x" :y="y" />
</template>
GOOD:
// composables/useMouse.ts
import { onMounted, onUnmounted, ref } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function onMove(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', onMove))
onUnmounted(() => window.removeEventListener('mousemove', onMove))
return { x, y }
}
<!-- MousePosition.vue -->
<script setup lang="ts">
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
</script>
<template>
<p>{{ x }}, {{ y }}</p>
</template>