Creare uno slideshow di immagini con Vue.js

Creare uno slideshow di immagini con Vue.js

Negli articoli precedenti di questa serie abbiamo costruito uno slideshow prima con il solo CSS e poi con JavaScript puro. La versione JavaScript era completa, ma ci costringeva a sincronizzare a mano lo stato e il DOM: ogni cambio di indice richiedeva di aggiornare classi, attributi, indicatori e pulsanti. In questo articolo riscriviamo lo slideshow con Vue.js 3, sfruttando la Composition API, i Single File Component con <script setup> e il componente <Transition>. Vedremo come la reattività di Vue ci permetta di descrivere l'interfaccia in modo dichiarativo e come estrarre la logica in un composable riutilizzabile.

Creazione del progetto

Partiamo da un progetto Vue generato con lo strumento ufficiale create-vue, basato su Vite. Non servono dipendenze aggiuntive: tutto ciò che useremo fa parte del core di Vue.

npm create vue@latest slideshow-vue
cd slideshow-vue
npm install
npm run dev

La struttura che realizzeremo è composta da tre file: un composable che contiene la logica dello slideshow, un componente che ne definisce la presentazione e il componente radice dell'applicazione che lo utilizza.

src/
  composables/
    useSlideshow.js
  components/
    ImageSlideshow.vue
  App.vue

Separare la logica dalla presentazione

Nella versione JavaScript la classe Slideshow mescolava due responsabilità: decidere qual è la slide corrente e quando cambiarla, e riflettere questa informazione nel DOM. Con Vue possiamo separarle in modo netto. Un composable, cioè una funzione che usa le API reattive di Vue e restituisce stato e operazioni, si occuperà della prima responsabilità; il template del componente della seconda.

Questa separazione ha un vantaggio concreto: la logica diventa indipendente dal markup. Lo stesso composable potrebbe alimentare un carosello di testimonianze, una galleria a schermo intero o una sequenza di schermate di onboarding, ognuno con un template diverso.

Il composable useSlideshow

Il composable riceve il numero di slide e le opzioni. Poiché questi valori arriveranno dalle props del componente, che possono cambiare nel tempo, li accettiamo come valori "risolvibili": un valore semplice, un ref oppure una funzione getter. La funzione toValue, introdotta in Vue 3.3, li normalizza tutti e, se chiamata all'interno di un contesto reattivo come un computed, ne traccia le dipendenze.

import { computed, onBeforeUnmount, onMounted, ref, toValue, watch } from 'vue';

export function useSlideshow(count, options = {}) {
  const {
    autoplay = true,
    interval = 5000,
    loop = true
  } = options;

  const currentIndex = ref(0);
  // Direzione dell'ultimo spostamento, usata per le animazioni
  const direction = ref('next');
  // Pausa esplicita richiesta dall'utente
  const isPaused = ref(false);
  // Pausa temporanea durante hover o focus
  const isInteracting = ref(false);
  const isDocumentVisible = ref(true);
  const prefersReducedMotion = ref(false);
  // Diventa true solo nel browser, dopo il montaggio
  const isMounted = ref(false);

  const total = computed(() => toValue(count));
  const isLooping = computed(() => toValue(loop));

  const canGoPrev = computed(() => isLooping.value || currentIndex.value > 0);
  const canGoNext = computed(() => isLooping.value || currentIndex.value < total.value - 1);

  const isRunning = computed(() =>
    isMounted.value
    && toValue(autoplay)
    && !isPaused.value
    && !isInteracting.value
    && !prefersReducedMotion.value
    && isDocumentVisible.value
    && total.value > 1
    && canGoNext.value
  );

  // ... continua
}

Lo stato è distribuito in diversi ref, ciascuno con un significato preciso. La riproduzione automatica, invece, non è uno stato a sé: è una conseguenza degli altri valori, e per questo la esprimiamo come proprietà calcolata. È lo stesso principio della condizione canAutoplay nella versione JavaScript, con una differenza fondamentale: lì dovevamo ricordarci di ricalcolarla a ogni cambiamento, qui Vue la ricalcola automaticamente quando una qualsiasi delle sue dipendenze cambia.

Includere canGoNext nella condizione fa sì che, con la navigazione circolare disattivata, la riproduzione si fermi da sola sull'ultima immagine. Il flag isMounted, infine, garantisce che il timer non venga mai avviato durante un eventuale rendering lato server.

La navigazione

Come nella versione precedente, tutte le operazioni passano da un'unica funzione che normalizza l'indice. In più, la funzione calcola la direzione dello spostamento, che useremo per animare le slide da destra o da sinistra.

function normalizeIndex(index) {
  const size = total.value;

  if (isLooping.value) {
    return ((index % size) + size) % size;
  }
  return Math.min(Math.max(index, 0), size - 1);
}

function goTo(index) {
  if (total.value === 0) {
    return;
  }

  const target = normalizeIndex(index);
  if (target === currentIndex.value) {
    return;
  }

  // Il confronto avviene sull'indice non normalizzato:
  // dall'ultima slide, "avanti" resta "avanti" anche quando si torna alla prima
  direction.value = index > currentIndex.value ? 'next' : 'prev';
  currentIndex.value = target;
}

const next = () => goTo(currentIndex.value + 1);
const prev = () => goTo(currentIndex.value - 1);

function togglePause() {
  isPaused.value = !isPaused.value;
}

// Se il numero di slide diminuisce, l'indice corrente resta valido
watch(total, (size) => {
  if (currentIndex.value > size - 1) {
    currentIndex.value = Math.max(size - 1, 0);
  }
});

Il dettaglio sulla direzione è sottile. Se siamo sull'ultima slide e premiamo "avanti", l'indice normalizzato torna a zero, che è minore dell'indice corrente; confrontando invece l'indice richiesto, che vale total, la direzione risulta correttamente "avanti" e l'animazione procede nel verso atteso.

La riproduzione automatica

Per la riproduzione automatica usiamo setTimeout invece di setInterval, e lasciamo che un watcher programmi il prossimo cambio ogni volta che qualcosa di rilevante cambia: lo stato di riproduzione, l'intervallo o l'indice corrente.

let timeoutId = null;

function clearTimer() {
  if (timeoutId !== null) {
    clearTimeout(timeoutId);
    timeoutId = null;
  }
}

function scheduleNext() {
  clearTimer();
  if (isRunning.value) {
    timeoutId = setTimeout(next, toValue(interval));
  }
}

// Ogni cambio di slide, manuale o automatico, fa ripartire il conteggio
watch([isRunning, currentIndex, () => toValue(interval)], scheduleNext);

Questa scelta semplifica molto il codice. Nella versione JavaScript dovevamo distinguere tra navigazione automatica e manuale per far ripartire il timer dopo un'azione dell'utente. Qui non serve: qualunque cambio dell'indice, da qualunque origine provenga, fa scattare il watcher, che cancella il timeout in corso e ne programma uno nuovo. Lo stesso accade quando l'utente mette in pausa, quando il puntatore entra nello slideshow o quando la scheda del browser viene nascosta: isRunning cambia e il watcher reagisce.

Gli eventi del browser e il ciclo di vita

Le preferenze sul movimento e la visibilità della pagina sono informazioni che appartengono al browser. Le leggiamo all'interno di onMounted, che non viene eseguito durante il rendering lato server, e rimuoviamo i listener in onBeforeUnmount.

let motionQuery = null;

function updateMotion() {
  prefersReducedMotion.value = motionQuery.matches;
}

function updateVisibility() {
  isDocumentVisible.value = document.visibilityState === 'visible';
}

onMounted(() => {
  motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
  updateMotion();
  updateVisibility();
  motionQuery.addEventListener('change', updateMotion);
  document.addEventListener('visibilitychange', updateVisibility);
  isMounted.value = true;
});

onBeforeUnmount(() => {
  clearTimer();
  motionQuery?.removeEventListener('change', updateMotion);
  document.removeEventListener('visibilitychange', updateVisibility);
});

return {
  currentIndex,
  direction,
  total,
  isPaused,
  isInteracting,
  isRunning,
  canGoPrev,
  canGoNext,
  goTo,
  next,
  prev,
  togglePause
};

Gli hook del ciclo di vita chiamati all'interno di un composable vengono associati al componente che lo invoca. Per questo il composable può occuparsi in autonomia della propria pulizia: chi lo usa non deve ricordarsi di fermare timer o rimuovere listener. È una delle caratteristiche più apprezzate della Composition API.

Il valore restituito espone lo stato e le operazioni. I ref restituiti restano reattivi anche dopo la destrutturazione, perché la reattività appartiene all'oggetto ref e non alla variabile che lo contiene.

Il componente ImageSlideshow: lo script

Il componente riceve l'elenco delle immagini e le opzioni come props, emette un evento change a ogni cambio di slide e gestisce le interazioni specifiche del DOM: tastiera, swipe, annunci per gli screen reader e precaricamento.

<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useSlideshow } from '../composables/useSlideshow.js';

const props = defineProps({
  images: {
    type: Array,
    required: true,
    // Ogni immagine deve avere almeno src e alt
    validator: (items) => items.every(
      (item) => typeof item.src === 'string' && typeof item.alt === 'string'
    )
  },
  autoplay: { type: Boolean, default: true },
  interval: { type: Number, default: 5000 },
  loop: { type: Boolean, default: true },
  label: { type: String, default: 'Galleria di immagini' }
});

const emit = defineEmits(['change']);

const {
  currentIndex,
  direction,
  total,
  isPaused,
  isInteracting,
  canGoPrev,
  canGoNext,
  goTo,
  next,
  prev,
  togglePause
} = useSlideshow(() => props.images.length, {
  autoplay: () => props.autoplay,
  interval: () => props.interval,
  loop: () => props.loop
});

const currentImage = computed(() => props.images[currentIndex.value]);
const transitionName = computed(() => `slide-${direction.value}`);
const statusMessage = ref('');
const swipeThreshold = 50;
let pointerStartX = null;

watch(currentIndex, (index, previousIndex) => {
  emit('change', { index, previousIndex });
  preloadImage(index + 1);
});

onMounted(() => preloadImage(1));

function preloadImage(index) {
  const image = props.images[index % props.images.length];
  if (image) {
    // Il browser scarica l'immagine e la mette in cache
    new Image().src = image.src;
  }
}

// Le azioni dell'utente vengono annunciate agli screen reader
function navigate(action) {
  action();
  statusMessage.value = `Immagine ${currentIndex.value + 1} di ${total.value}`;
}

function onKeydown(event) {
  const actions = {
    ArrowLeft: prev,
    ArrowRight: next,
    Home: () => goTo(0),
    End: () => goTo(total.value - 1)
  };
  const action = actions[event.key];
  if (action) {
    event.preventDefault();
    navigate(action);
  }
}

function onFocusOut(event) {
  // Riprendi solo se il focus esce davvero dal componente
  if (!event.currentTarget.contains(event.relatedTarget)) {
    isInteracting.value = false;
  }
}

function onPointerDown(event) {
  if (event.isPrimary && event.button === 0) {
    pointerStartX = event.clientX;
  }
}

function resetPointer() {
  pointerStartX = null;
}

function onPointerUp(event) {
  if (pointerStartX === null) {
    return;
  }
  const deltaX = event.clientX - pointerStartX;
  pointerStartX = null;
  if (Math.abs(deltaX) >= swipeThreshold) {
    navigate(deltaX < 0 ? next : prev);
  }
}
</script>

Le props autoplay, interval e loop vengono passate al composable come funzioni getter. Se le passassimo direttamente, ad esempio scrivendo autoplay: props.autoplay, il composable riceverebbe un valore booleano fissato al momento della creazione e non si accorgerebbe dei cambiamenti successivi. La funzione getter, letta con toValue all'interno di un computed, mantiene invece il collegamento reattivo con la prop.

A differenza delle versioni CSS e JavaScript, in questo componente nel DOM esiste una sola slide alla volta, come vedremo nel template. Per questo motivo il precaricamento diventa importante: senza di esso, il browser inizierebbe a scaricare l'immagine successiva solo nel momento in cui deve mostrarla, e la transizione partirebbe con un riquadro vuoto. Creare un oggetto Image e assegnargli l'URL è sufficiente a far scaricare il file e a inserirlo nella cache.

Il componente ImageSlideshow: il template

Il template descrive come deve apparire lo slideshow in funzione dello stato. Non contiene una sola istruzione che modifichi il DOM: le classi, gli attributi e il contenuto vengono derivati dalle variabili reattive, e Vue si occupa di aggiornare la pagina quando queste cambiano.

<template>
  <figure
    class="slideshow"
    aria-roledescription="carousel"
    :aria-label="label"
    @mouseenter="isInteracting = true"
    @mouseleave="isInteracting = false"
    @focusin="isInteracting = true"
    @focusout="onFocusOut"
    @keydown="onKeydown"
  >
    <ul
      class="slideshow-slides"
      @pointerdown="onPointerDown"
      @pointerup="onPointerUp"
      @pointercancel="resetPointer"
    >
      <Transition :name="transitionName">
        <li
          v-if="currentImage"
          :key="currentIndex"
          class="slideshow-slide"
          aria-roledescription="slide"
          :aria-label="`${currentIndex + 1} di ${total}`"
        >
          <img :src="currentImage.src" :alt="currentImage.alt" draggable="false">
          <p v-if="currentImage.caption" class="slideshow-caption">{{ currentImage.caption }}</p>
        </li>
      </Transition>
    </ul>

    <button
      type="button"
      class="slideshow-arrow slideshow-prev"
      aria-label="Immagine precedente"
      :disabled="!canGoPrev"
      @click="navigate(prev)"
    >&#8249;</button>
    <button
      type="button"
      class="slideshow-arrow slideshow-next"
      aria-label="Immagine successiva"
      :disabled="!canGoNext"
      @click="navigate(next)"
    >&#8250;</button>

    <button
      v-if="autoplay"
      type="button"
      class="slideshow-toggle"
      :aria-pressed="isPaused"
      @click="togglePause"
    >{{ isPaused ? 'Riprendi' : 'Pausa' }}</button>

    <nav class="slideshow-dots" aria-label="Seleziona immagine">
      <button
        v-for="(image, index) in images"
        :key="image.src"
        type="button"
        class="slideshow-dot"
        :aria-label="`Vai all'immagine ${index + 1}`"
        :aria-current="index === currentIndex ? 'true' : undefined"
        @click="navigate(() => goTo(index))"
      ></button>
    </nav>

    <p class="slideshow-status visually-hidden" aria-live="polite">{{ statusMessage }}</p>
  </figure>
</template>

La parte più interessante è l'uso di <Transition> insieme all'attributo :key. Quando currentIndex cambia, cambia anche la chiave dell'elemento <li>; Vue non riutilizza il vecchio elemento aggiornandone gli attributi, ma lo considera un elemento diverso, lo rimuove e ne inserisce uno nuovo. Il componente <Transition> intercetta questa sostituzione e applica le classi di animazione all'elemento uscente e a quello entrante, che restano nel DOM contemporaneamente per tutta la durata dell'animazione.

Il nome della transizione è dinamico e dipende dalla direzione: slide-next o slide-prev. In questo modo, con un'unica dichiarazione, otteniamo animazioni diverse a seconda che l'utente vada avanti o indietro.

Per l'attributo aria-current passiamo undefined quando l'indicatore non è attivo: Vue rimuove gli attributi il cui valore è undefined o null. Per aria-pressed, invece, Vue converte il booleano nelle stringhe "true" e "false", come richiesto dalle specifiche ARIA.

Nel codice non compare la gestione di inert né di aria-hidden sulle slide nascoste, che nella versione JavaScript richiedeva un ciclo dedicato. Non serve: le slide nascoste semplicemente non esistono nel DOM.

Il componente ImageSlideshow: gli stili

Gli stili sono dichiarati con l'attributo scoped, che li limita al componente. Oltre al layout già visto negli articoli precedenti, definiamo le classi delle transizioni secondo la convenzione di Vue: -enter-from e -leave-to descrivono lo stato iniziale dell'elemento entrante e quello finale dell'elemento uscente, mentre -enter-active e -leave-active restano applicate per tutta la durata dell'animazione.

<style scoped>
.slideshow {
  position: relative;
  max-width: 960px;
  margin: 0 auto;
  overflow: hidden;
  border-radius: 8px;
  background: #111;
}

.slideshow-slides {
  position: relative;
  aspect-ratio: 16 / 9;
  margin: 0;
  padding: 0;
  list-style: none;
  touch-action: pan-y;
}

/* Le due slide coinvolte nella transizione si sovrappongono */
.slideshow-slide {
  position: absolute;
  inset: 0;
}

.slideshow-slide img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  user-select: none;
}

.slideshow-caption {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  margin: 0;
  padding: 2.5rem 1.25rem 1rem;
  color: #fff;
  background: linear-gradient(transparent, rgb(0 0 0 / 0.7));
}

.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
  transition: transform 0.5s ease;
}

/* Avanti: la nuova slide entra da destra, la vecchia esce a sinistra */
.slide-next-enter-from {
  transform: translateX(100%);
}

.slide-next-leave-to {
  transform: translateX(-100%);
}

/* Indietro: il movimento è speculare */
.slide-prev-enter-from {
  transform: translateX(-100%);
}

.slide-prev-leave-to {
  transform: translateX(100%);
}

.slideshow-arrow {
  position: absolute;
  top: 50%;
  translate: 0 -50%;
  width: 3rem;
  height: 3rem;
  border: 0;
  border-radius: 50%;
  font-size: 2rem;
  line-height: 1;
  color: #fff;
  background: rgb(0 0 0 / 0.45);
  cursor: pointer;
}

.slideshow-arrow:disabled {
  opacity: 0.3;
  cursor: default;
}

.slideshow-prev {
  left: 1rem;
}

.slideshow-next {
  right: 1rem;
}

.slideshow-toggle {
  position: absolute;
  top: 1rem;
  right: 1rem;
  padding: 0.4rem 0.8rem;
  border: 0;
  border-radius: 4px;
  color: #fff;
  background: rgb(0 0 0 / 0.55);
  cursor: pointer;
}

.slideshow-dots {
  position: absolute;
  top: 1rem;
  left: 50%;
  translate: -50% 0;
  display: flex;
  gap: 0.5rem;
}

.slideshow-dot {
  width: 0.75rem;
  height: 0.75rem;
  padding: 0;
  border: 2px solid #fff;
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
}

.slideshow-dot[aria-current="true"] {
  background: #fff;
}

.slideshow button:focus-visible {
  outline: 3px solid #4da3ff;
  outline-offset: 3px;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Con il movimento ridotto le slide si sostituiscono senza scorrere */
@media (prefers-reduced-motion: reduce) {
  .slide-next-enter-active,
  .slide-next-leave-active,
  .slide-prev-enter-active,
  .slide-prev-leave-active {
    transition: none;
  }
}
</style>

Il posizionamento assoluto delle slide è indispensabile per il funzionamento della transizione. Durante l'animazione l'elemento uscente e quello entrante sono entrambi presenti nel DOM; se fossero nel normale flusso del documento, il secondo verrebbe posizionato sotto il primo. Sovrapponendoli, possiamo farli scorrere orizzontalmente uno accanto all'altro. La proprietà overflow: hidden sul contenitore nasconde la parte delle slide che si trova fuori dall'area visibile.

Gli stili scoped funzionano anche con le classi delle transizioni, perché Vue aggiunge l'attributo di scope all'elemento <li>, che appartiene al template del componente.

L'utilizzo nel componente radice

Il componente è pronto per essere usato. Nel componente radice definiamo l'elenco delle immagini e ascoltiamo l'evento change.

<script setup>
import ImageSlideshow from './components/ImageSlideshow.vue';

const images = [
  { src: '/images/mountains.jpg', alt: "Catena montuosa all'alba", caption: "Le montagne all'alba" },
  { src: '/images/lake.jpg', alt: 'Lago alpino circondato da abeti', caption: 'Un lago alpino' },
  { src: '/images/coast.jpg', alt: 'Costa rocciosa al tramonto', caption: 'La costa al tramonto' }
];

function onSlideChange({ index, previousIndex }) {
  console.log(`Slide cambiata: da ${previousIndex + 1} a ${index + 1}`);
}
</script>

<template>
  <ImageSlideshow
    :images="images"
    :interval="4000"
    label="Galleria di viaggio"
    @change="onSlideChange"
  />
</template>

Le immagini con percorso assoluto vengono servite dalla cartella public del progetto Vite. Poiché le props sono reattive, se l'elenco delle immagini provenisse da una chiamata a un'API e venisse aggiornato in un secondo momento, lo slideshow si adatterebbe automaticamente: gli indicatori verrebbero rigenerati e l'indice corrente verrebbe corretto dal watcher sul totale.

Possibili estensioni

La struttura che abbiamo costruito si presta a diverse estensioni. Si potrebbe esporre l'indice corrente tramite defineModel, così che il componente padre possa sia leggerlo sia impostarlo con v-model:index. Si potrebbe usare defineExpose per rendere disponibili i metodi next, prev e goTo a un componente padre che ottiene un riferimento allo slideshow tramite ref. Infine, il composable può essere testato in isolamento con Vitest, montando un componente minimale che lo invoca e usando i timer simulati per verificare la riproduzione automatica senza attese reali.

Conclusioni

Rispetto alla versione in JavaScript puro, il codice di Vue è più breve e, soprattutto, più difficile da sbagliare. Non dobbiamo più sincronizzare a mano stato e DOM: il template dichiara come l'interfaccia dipende dallo stato e la reattività si occupa del resto. La riproduzione automatica si riduce a una proprietà calcolata e a un watcher, e il componente <Transition> ci ha permesso di implementare animazioni direzionali con poche regole CSS. La separazione tra composable e componente, infine, rende la logica riutilizzabile e testabile in modo indipendente.

Nel prossimo articolo affronteremo lo stesso problema con React, che adotta un modello diverso: nessuna reattività basata su proxy, ma stato immutabile, funzioni di rendering ed effetti espliciti.