Slot
Cos’e uno slot
Gli slot permettono a un componente genitore di iniettare contenuto all’interno del template di un componente figlio. Sono il meccanismo principale per la composizione dei componenti in Vue.
Slot default
Lo slot base (senza nome) accetta qualsiasi contenuto passato tra i tag del componente.
<!-- BaseCard.vue -->
<script setup lang="ts">
defineProps<{
titolo: string
}>()
</script>
<template>
<div class="card">
<div class="card-header">
<h3>{{ titolo }}</h3>
</div>
<div class="card-body">
<!-- Lo slot renderizza il contenuto passato dal genitore -->
<slot />
</div>
</div>
</template>
<style scoped>
.card {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.card-header {
background: #f7fafc;
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
}
.card-body {
padding: 1rem;
}
</style>
<!-- Utilizzo -->
<template>
<BaseCard titolo="Profilo utente">
<!-- Questo contenuto viene inserito nello slot -->
<p>Nome: Mario Rossi</p>
<p>Email: mario@example.com</p>
<button>Modifica profilo</button>
</BaseCard>
<BaseCard titolo="Statistiche">
<ul>
<li>Visite: 1234</li>
<li>Commenti: 56</li>
</ul>
</BaseCard>
</template>
Slot fallback
Lo slot puo avere un contenuto predefinito che viene mostrato quando il genitore non fornisce contenuto.
<!-- BaseButton.vue -->
<script setup lang="ts">
defineProps<{
variante?: 'primario' | 'secondario' | 'pericolo'
}>()
</script>
<template>
<button :class="['btn', `btn-${variante || 'primario'}`]">
<!-- Fallback: mostrato se non viene passato contenuto -->
<slot>Clicca qui</slot>
</button>
</template>
<!-- Utilizzo -->
<template>
<!-- Usa il fallback "Clicca qui" -->
<BaseButton />
<!-- Sovrascrive con contenuto personalizzato -->
<BaseButton variante="pericolo">
Elimina account
</BaseButton>
<!-- Contenuto complesso -->
<BaseButton>
<span class="icona">🔍</span>
Cerca
</BaseButton>
</template>
Slot con nome (named slots)
Quando un componente ha piu aree di contenuto, usa slot con nome per distinguerle.
<!-- LayoutPagina.vue -->
<template>
<div class="layout">
<header>
<slot name="header" />
</header>
<aside>
<slot name="sidebar" />
</aside>
<main>
<!-- Lo slot senza nome e lo slot "default" -->
<slot />
</main>
<footer>
<slot name="footer" />
</footer>
</div>
</template>
<style scoped>
.layout {
display: grid;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
grid-template-columns: 250px 1fr;
min-height: 100vh;
}
header { grid-area: header; }
aside { grid-area: sidebar; }
main { grid-area: main; }
footer { grid-area: footer; }
</style>
<!-- Utilizzo con v-slot (abbreviato #) -->
<template>
<LayoutPagina>
<template #header>
<nav>
<a href="/">Home</a>
<a href="/prodotti">Prodotti</a>
</nav>
</template>
<template #sidebar>
<ul>
<li>Categoria 1</li>
<li>Categoria 2</li>
<li>Categoria 3</li>
</ul>
</template>
<!-- Contenuto default (senza #) -->
<h1>Benvenuto</h1>
<p>Contenuto principale della pagina.</p>
<template #footer>
<p>© 2026 Mio Sito</p>
</template>
</LayoutPagina>
</template>
Slot condizionali
Puoi verificare se uno slot ha contenuto con $slots.
<!-- BaseCard.vue migliorato -->
<script setup lang="ts">
import { useSlots } from 'vue'
const slots = useSlots()
const haHeader = !!slots.header
const haFooter = !!slots.footer
</script>
<template>
<div class="card">
<div v-if="haHeader" class="card-header">
<slot name="header" />
</div>
<div class="card-body">
<slot />
</div>
<div v-if="haFooter" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
Scoped Slot
Gli scoped slot permettono al componente figlio di passare dati al contenuto dello slot. Il figlio “espone” dati che il genitore puo utilizzare nel template dello slot.
Esempio base
<!-- ListaElementi.vue -->
<script setup lang="ts">
import { ref } from 'vue'
interface Elemento {
id: number
nome: string
completato: boolean
}
const props = defineProps<{
elementi: Elemento[]
}>()
const emit = defineEmits<{
toggle: [id: number]
rimuovi: [id: number]
}>()
</script>
<template>
<ul class="lista">
<li v-for="(elemento, indice) in elementi" :key="elemento.id">
<!-- Passa dati al genitore tramite lo slot -->
<slot
:elemento="elemento"
:indice="indice"
:toggle="() => emit('toggle', elemento.id)"
:rimuovi="() => emit('rimuovi', elemento.id)"
/>
</li>
</ul>
</template>
<!-- Utilizzo: il genitore decide come renderizzare ogni elemento -->
<script setup lang="ts">
import { ref } from 'vue'
import ListaElementi from './ListaElementi.vue'
const compiti = ref([
{ id: 1, nome: 'Fare la spesa', completato: false },
{ id: 2, nome: 'Lavare i piatti', completato: true },
{ id: 3, nome: 'Studiare Vue', completato: false }
])
function toggleCompito(id: number) {
const compito = compiti.value.find(c => c.id === id)
if (compito) compito.completato = !compito.completato
}
function rimuoviCompito(id: number) {
compiti.value = compiti.value.filter(c => c.id !== id)
}
</script>
<template>
<!-- Visualizzazione semplice -->
<ListaElementi
:elementi="compiti"
@toggle="toggleCompito"
@rimuovi="rimuoviCompito"
>
<template #default="{ elemento, toggle, rimuovi }">
<span
:style="{ textDecoration: elemento.completato ? 'line-through' : 'none' }"
@click="toggle"
>
{{ elemento.nome }}
</span>
<button @click="rimuovi">X</button>
</template>
</ListaElementi>
</template>
Scoped slot con destrutturazione
<!-- Destrutturazione diretta delle props dello slot -->
<template>
<ListaElementi :elementi="compiti">
<!-- Destrutturazione con rinomina -->
<template #default="{ elemento: compito, indice: i }">
<span>{{ i + 1 }}. {{ compito.nome }}</span>
</template>
</ListaElementi>
</template>
Scoped slot con nome
<!-- TabellaAvanzata.vue -->
<script setup lang="ts">
interface Colonna {
chiave: string
etichetta: string
}
defineProps<{
colonne: Colonna[]
righe: Record<string, any>[]
}>()
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in colonne" :key="col.chiave">
<!-- Slot per personalizzare l'header -->
<slot :name="`header-${col.chiave}`" :colonna="col">
{{ col.etichetta }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(riga, indice) in righe" :key="indice">
<td v-for="col in colonne" :key="col.chiave">
<!-- Slot per personalizzare la cella -->
<slot :name="`cella-${col.chiave}`" :valore="riga[col.chiave]" :riga="riga">
{{ riga[col.chiave] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<!-- Utilizzo della tabella personalizzabile -->
<script setup>
const colonne = [
{ chiave: 'nome', etichetta: 'Nome' },
{ chiave: 'email', etichetta: 'Email' },
{ chiave: 'stato', etichetta: 'Stato' }
]
const utenti = [
{ nome: 'Mario', email: 'mario@test.com', stato: 'attivo' },
{ nome: 'Luigi', email: 'luigi@test.com', stato: 'inattivo' }
]
</script>
<template>
<TabellaAvanzata :colonne="colonne" :righe="utenti">
<!-- Personalizza come viene renderizzata la colonna "stato" -->
<template #cella-stato="{ valore }">
<span :class="['badge', valore]">
{{ valore === 'attivo' ? 'Attivo' : 'Inattivo' }}
</span>
</template>
<!-- Personalizza l'header della colonna "nome" -->
<template #header-nome>
<strong>Nome Utente</strong>
</template>
</TabellaAvanzata>
</template>
Pattern di composizione con slot
Renderless Component
Un componente senza template proprio che fornisce solo logica tramite scoped slot.
<!-- FetchDati.vue - Componente renderless -->
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
const props = defineProps<{
url: string
}>()
const dati = ref<any>(null)
const errore = ref<string | null>(null)
const isCaricamento = ref(true)
async function carica() {
isCaricamento.value = true
errore.value = null
try {
const risposta = await fetch(props.url)
if (!risposta.ok) throw new Error(`HTTP ${risposta.status}`)
dati.value = await risposta.json()
} catch (e) {
errore.value = (e as Error).message
} finally {
isCaricamento.value = false
}
}
onMounted(carica)
watch(() => props.url, carica)
</script>
<template>
<slot
:dati="dati"
:errore="errore"
:isCaricamento="isCaricamento"
:ricarica="carica"
/>
</template>
<!-- Utilizzo -->
<template>
<FetchDati url="/api/utenti">
<template #default="{ dati, errore, isCaricamento, ricarica }">
<div v-if="isCaricamento">Caricamento...</div>
<div v-else-if="errore">
Errore: {{ errore }}
<button @click="ricarica">Riprova</button>
</div>
<ul v-else>
<li v-for="utente in dati" :key="utente.id">
{{ utente.nome }}
</li>
</ul>
</template>
</FetchDati>
</template>
Pattern contenitore/contenuto
<!-- Accordion.vue -->
<script setup lang="ts">
import { ref, provide } from 'vue'
const elementoAperto = ref<string | null>(null)
function toggle(id: string) {
elementoAperto.value = elementoAperto.value === id ? null : id
}
provide('accordion', { elementoAperto, toggle })
</script>
<template>
<div class="accordion">
<slot />
</div>
</template>
<!-- AccordionItem.vue -->
<script setup lang="ts">
import { inject, computed } from 'vue'
const props = defineProps<{
id: string
titolo: string
}>()
const { elementoAperto, toggle } = inject('accordion') as any
const isAperto = computed(() => elementoAperto.value === props.id)
</script>
<template>
<div class="accordion-item">
<button @click="toggle(id)" class="accordion-header">
{{ titolo }}
<span>{{ isAperto ? '−' : '+' }}</span>
</button>
<div v-show="isAperto" class="accordion-body">
<slot />
</div>
</div>
</template>
<!-- Utilizzo -->
<template>
<Accordion>
<AccordionItem id="1" titolo="Sezione 1">
<p>Contenuto della prima sezione.</p>
</AccordionItem>
<AccordionItem id="2" titolo="Sezione 2">
<p>Contenuto della seconda sezione.</p>
</AccordionItem>
<AccordionItem id="3" titolo="Sezione 3">
<p>Contenuto della terza sezione.</p>
</AccordionItem>
</Accordion>
</template>
Best practice
- Usa slot per la composizione: Preferisci slot rispetto a props complesse per il contenuto
- Fornisci fallback significativi: Definisci sempre un contenuto predefinito utile
- Scoped slot per la personalizzazione: Quando il figlio ha dati che il genitore deve visualizzare
- Usa nomi descrittivi:
#header,#footer,#azionisono meglio di#slot1,#slot2 - Verifica l’esistenza dello slot: Usa
useSlots()per rendering condizionale - Preferisci composables ai renderless components: Nella maggior parte dei casi i composables sono piu semplici