Creare uno slideshow di immagini con i CSS

Creare uno slideshow di immagini con i CSS

Lo slideshow di immagini è uno dei componenti più diffusi nelle interfacce web e, nella maggior parte dei casi, viene realizzato con JavaScript. Eppure il CSS moderno offre strumenti sufficienti per costruire uno slideshow perfettamente funzionante senza scrivere una sola riga di script: selettori di stato come :checked, il combinatore di fratelli generale ~, le transizioni, le animazioni con @keyframes e lo scroll snapping. In questo articolo, il primo di una serie dedicata alla creazione di slideshow con tecnologie diverse, costruiremo passo dopo passo tre varianti esclusivamente in HTML e CSS: uno slideshow navigabile basato sui radio button, uno slideshow automatico basato sulle animazioni e uno slideshow scorrevole basato sullo scroll snapping.

L'obiettivo non è soltanto ottenere un risultato visivo, ma capire a fondo i meccanismi su cui ciascuna tecnica si fonda, i suoi punti di forza e i suoi limiti. Questa comprensione ci sarà utile anche negli articoli successivi, perché la parte di presentazione rimarrà in gran parte la stessa anche quando la logica passerà a JavaScript, Vue.js, React e Angular.

Il problema dello stato senza JavaScript

Uno slideshow è, nella sua essenza, un componente con uno stato: in ogni istante esiste una e una sola immagine attiva, e l'interazione dell'utente modifica quale immagine è attiva. Il CSS non dispone di variabili modificabili dall'utente, ma può leggere lo stato di alcuni elementi HTML che lo mantengono in modo nativo. L'esempio più utile è il gruppo di radio button: i radio che condividono lo stesso attributo name garantiscono per costruzione che uno solo di essi sia selezionato in un dato momento. È esattamente l'invariante che ci serve.

Gli elementi <label> associati ai radio tramite l'attributo for completano il quadro: un clic su un'etichetta seleziona il radio corrispondente, ovunque si trovi l'etichetta nel documento. In questo modo possiamo disporre i controlli di navigazione dove preferiamo, mentre lo stato rimane custodito dai radio.

La struttura HTML

La struttura dello slideshow è composta da tre parti: i radio button che mantengono lo stato, la lista delle slide e la barra degli indicatori. È fondamentale che i radio precedano, come fratelli, sia la lista sia la barra degli indicatori, perché il combinatore ~ seleziona solo i fratelli successivi di un elemento.

<figure class="slideshow" aria-label="Galleria di viaggio">
  <!-- I radio mantengono lo stato: uno solo può essere selezionato -->
  <input type="radio" name="slideshow" id="slide-1" class="slideshow-radio" checked>
  <input type="radio" name="slideshow" id="slide-2" class="slideshow-radio">
  <input type="radio" name="slideshow" id="slide-3" class="slideshow-radio">

  <ul class="slideshow-slides">
    <li class="slideshow-slide">
      <img src="images/mountains.jpg" alt="Catena montuosa all'alba" width="1600" height="900">
      <p class="slideshow-caption">Le montagne all'alba</p>
      <!-- Frecce: ogni slide punta ai radio della precedente e della successiva -->
      <label for="slide-3" class="slideshow-arrow slideshow-arrow-prev" aria-hidden="true">&#8249;</label>
      <label for="slide-2" class="slideshow-arrow slideshow-arrow-next" aria-hidden="true">&#8250;</label>
    </li>
    <li class="slideshow-slide">
      <img src="images/lake.jpg" alt="Lago alpino circondato da abeti" width="1600" height="900" loading="lazy">
      <p class="slideshow-caption">Un lago alpino</p>
      <label for="slide-1" class="slideshow-arrow slideshow-arrow-prev" aria-hidden="true">&#8249;</label>
      <label for="slide-3" class="slideshow-arrow slideshow-arrow-next" aria-hidden="true">&#8250;</label>
    </li>
    <li class="slideshow-slide">
      <img src="images/coast.jpg" alt="Costa rocciosa al tramonto" width="1600" height="900" loading="lazy">
      <p class="slideshow-caption">La costa al tramonto</p>
      <label for="slide-2" class="slideshow-arrow slideshow-arrow-prev" aria-hidden="true">&#8249;</label>
      <label for="slide-1" class="slideshow-arrow slideshow-arrow-next" aria-hidden="true">&#8250;</label>
    </li>
  </ul>

  <!-- Indicatori: un'etichetta per ciascun radio -->
  <nav class="slideshow-dots" aria-label="Seleziona immagine">
    <label for="slide-1" class="slideshow-dot"><span class="visually-hidden">Immagine 1</span></label>
    <label for="slide-2" class="slideshow-dot"><span class="visually-hidden">Immagine 2</span></label>
    <label for="slide-3" class="slideshow-dot"><span class="visually-hidden">Immagine 3</span></label>
  </nav>
</figure>

Alcune scelte meritano un commento. Il contenitore è un elemento <figure>, adatto a raggruppare contenuti illustrativi autonomi. Le slide sono elementi di una lista non ordinata, perché semanticamente rappresentano un insieme di elementi omogenei, e gli screen reader ne annunciano il numero. Le immagini dichiarano gli attributi width e height: il browser ne ricava il rapporto d'aspetto prima ancora di scaricarle, evitando spostamenti del layout. Infine, la navigazione circolare è codificata direttamente nell'HTML: la freccia "precedente" della prima slide punta al radio della terza e la freccia "successiva" della terza punta al radio della prima.

Le frecce sono marcate con aria-hidden="true" perché per chi usa la tastiera o uno screen reader esiste già un meccanismo di navigazione più efficace, che vedremo tra poco: i radio stessi.

Il layout di base

Le slide vengono sovrapposte l'una all'altra all'interno di un contenitore con un rapporto d'aspetto fisso. Tutte occupano la stessa area grazie al posizionamento assoluto con inset: 0, e solo quella attiva sarà visibile.

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

/* I radio restano nel DOM e focalizzabili, ma invisibili */
.slideshow-radio {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

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

.slideshow-slide {
  position: absolute;
  inset: 0;
  opacity: 0;
  visibility: hidden;
  /* visibility cambia solo al termine della dissolvenza in uscita */
  transition: opacity 0.6s ease, visibility 0s linear 0.6s;
}

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

.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));
}

È importante notare che i radio non vengono nascosti con display: none. Un elemento con display: none esce dall'albero di accessibilità e non può ricevere il focus; usando invece opacity: 0 i radio rimangono utilizzabili da tastiera, e questo ci regalerà la navigazione con i tasti freccia senza alcuno sforzo aggiuntivo.

La transizione sulle slide combina due proprietà. L'opacità produce la dissolvenza, mentre visibility rimuove dall'interazione le slide nascoste: senza di essa, le frecce delle slide trasparenti continuerebbero a intercettare i clic, perché un elemento con opacità zero è comunque presente e cliccabile. Il ritardo di 0,6 secondi sulla transizione di visibility fa sì che la slide in uscita resti visibile per tutta la durata della dissolvenza, e diventi nascosta solo alla fine.

Mostrare la slide attiva con :checked

Il cuore della tecnica è una serie di selettori che collegano ciascun radio alla slide corrispondente. Il selettore legge: "quando il radio slide-1 è selezionato, prendi il fratello successivo con classe slideshow-slides e, al suo interno, il primo elemento della lista".

#slide-1:checked ~ .slideshow-slides .slideshow-slide:nth-child(1),
#slide-2:checked ~ .slideshow-slides .slideshow-slide:nth-child(2),
#slide-3:checked ~ .slideshow-slides .slideshow-slide:nth-child(3) {
  opacity: 1;
  visibility: visible;
  /* La slide in entrata diventa visibile subito, poi si dissolve */
  transition: opacity 0.6s ease;
}

Nella slide attiva la transizione di visibility non ha ritardo, così l'elemento diventa visibile immediatamente e l'opacità può animarsi da zero a uno. Il risultato è una dissolvenza incrociata: la slide uscente sfuma verso la trasparenza mentre quella entrante emerge, e le due si sovrappongono per tutta la durata dell'effetto.

Lo svantaggio di questo approccio è evidente: serve una regola per ogni slide. Con tre o cinque immagini è accettabile, con venti diventa scomodo. Nella pratica questi selettori si generano con un preprocessore come Sass oppure lato server, insieme al markup. Con Sass, ad esempio, bastano poche righe.

$slide-count: 3;

@for $i from 1 through $slide-count {
  #slide-#{$i}:checked ~ .slideshow-slides .slideshow-slide:nth-child(#{$i}) {
    opacity: 1;
    visibility: visible;
    transition: opacity 0.6s ease;
  }

  #slide-#{$i}:checked ~ .slideshow-dots .slideshow-dot:nth-child(#{$i}) {
    background: #fff;
  }
}

Le frecce di navigazione

Le frecce sono semplici etichette posizionate ai lati della slide. Poiché ogni slide contiene le proprie frecce e solo la slide attiva è visibile e interattiva, l'utente vede e clicca sempre le frecce giuste.

.slideshow-arrow {
  position: absolute;
  top: 50%;
  translate: 0 -50%;
  display: grid;
  place-items: center;
  width: 3rem;
  height: 3rem;
  border-radius: 50%;
  font-size: 2rem;
  line-height: 1;
  color: #fff;
  background: rgb(0 0 0 / 0.45);
  cursor: pointer;
  user-select: none;
  transition: background 0.2s ease;
}

.slideshow-arrow:hover {
  background: rgb(0 0 0 / 0.75);
}

.slideshow-arrow-prev {
  left: 1rem;
}

.slideshow-arrow-next {
  right: 1rem;
}

La proprietà translate, disponibile come proprietà autonoma in tutti i browser moderni, centra verticalmente la freccia senza interferire con un eventuale transform applicato in seguito. La proprietà user-select: none evita che un doppio clic rapido sulle frecce selezioni il carattere come testo.

Gli indicatori

Gli indicatori mostrano quante immagini compongono lo slideshow e quale è attiva. Anche in questo caso lo stato viene letto dai radio: il radio selezionato colora l'etichetta corrispondente nella barra.

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

.slideshow-dot {
  width: 0.75rem;
  height: 0.75rem;
  border: 2px solid #fff;
  border-radius: 50%;
  cursor: pointer;
  transition: background 0.2s ease;
}

#slide-1:checked ~ .slideshow-dots .slideshow-dot:nth-child(1),
#slide-2:checked ~ .slideshow-dots .slideshow-dot:nth-child(2),
#slide-3:checked ~ .slideshow-dots .slideshow-dot:nth-child(3) {
  background: #fff;
}

/* Testo leggibile solo dagli screen reader */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

La classe visually-hidden è un pattern consolidato: il testo rimane nell'albero di accessibilità e viene letto dagli screen reader, ma non occupa spazio visivo. È il modo corretto per dare un nome accessibile a controlli puramente grafici come i pallini degli indicatori.

Navigazione da tastiera e focus

Qui emerge il vantaggio più interessante della tecnica basata sui radio. Quando un radio di un gruppo riceve il focus, il browser permette di cambiare selezione con i tasti freccia: è un comportamento nativo, previsto dalle specifiche HTML. Premendo Tab si raggiunge il gruppo di radio e con le frecce sinistra e destra si scorre lo slideshow, con navigazione circolare inclusa.

Resta da rendere visibile il focus, dato che i radio sono trasparenti. Possiamo sfruttare :focus-visible sul radio per evidenziare l'indicatore corrispondente.

#slide-1:focus-visible ~ .slideshow-dots .slideshow-dot:nth-child(1),
#slide-2:focus-visible ~ .slideshow-dots .slideshow-dot:nth-child(2),
#slide-3:focus-visible ~ .slideshow-dots .slideshow-dot:nth-child(3) {
  outline: 3px solid #4da3ff;
  outline-offset: 3px;
}

La pseudo-classe :focus-visible si attiva solo quando il browser ritiene utile mostrare il focus, tipicamente durante la navigazione da tastiera, e non dopo un clic del mouse. In questo modo l'anello di focus compare quando serve e non disturba chi usa il puntatore.

Rispettare le preferenze sul movimento

Alcuni utenti configurano il sistema operativo per ridurre le animazioni, ad esempio a causa di disturbi vestibolari. La media query prefers-reduced-motion ci permette di rispettare questa scelta eliminando la dissolvenza e rendendo il cambio di immagine istantaneo.

@media (prefers-reduced-motion: reduce) {
  .slideshow-slide,
  .slideshow-arrow,
  .slideshow-dot {
    transition: none !important;
  }
}

Seconda variante: lo slideshow automatico

Se lo slideshow deve scorrere da solo, ad esempio in una pagina di presentazione, i radio non servono più: la logica temporale può essere affidata interamente alle animazioni CSS. L'idea è assegnare a tutte le slide la stessa animazione, che le rende visibili per una frazione del ciclo, sfasandole nel tempo con animation-delay.

<figure class="slideshow slideshow-auto" aria-label="Galleria automatica">
  <ul class="slideshow-slides">
    <li class="slideshow-slide" style="--index: 0">
      <img src="images/mountains.jpg" alt="Catena montuosa all'alba" width="1600" height="900">
    </li>
    <li class="slideshow-slide" style="--index: 1">
      <img src="images/lake.jpg" alt="Lago alpino circondato da abeti" width="1600" height="900">
    </li>
    <li class="slideshow-slide" style="--index: 2">
      <img src="images/coast.jpg" alt="Costa rocciosa al tramonto" width="1600" height="900">
    </li>
  </ul>
</figure>

Ogni slide dichiara la propria posizione tramite la custom property --index. In questo modo il ritardo può essere calcolato con calc() invece di essere scritto a mano per ogni elemento. L'uso dell'attributo style in questo caso è giustificato: non stiamo definendo un aspetto, ma passiamo un dato al foglio di stile.

Con tre slide e quattro secondi per slide, il ciclo completo dura dodici secondi. Ciascuna slide deve essere visibile per un terzo del ciclo, cioè dallo 0% al 33% circa, includendo brevi intervalli per la dissolvenza in entrata e in uscita.

.slideshow-auto {
  --slide-duration: 4s;
  --slide-count: 3;
  --cycle-duration: calc(var(--slide-duration) * var(--slide-count));
}

.slideshow-auto .slideshow-slide {
  visibility: visible;
  /* both: prima dell'avvio la slide assume lo stato del primo keyframe */
  animation: slideshow-fade var(--cycle-duration) infinite both;
  animation-delay: calc(var(--slide-duration) * var(--index));
}

@keyframes slideshow-fade {
  0% {
    opacity: 0;
  }
  4% {
    opacity: 1;
  }
  33% {
    opacity: 1;
  }
  37% {
    opacity: 0;
  }
  100% {
    opacity: 0;
  }
}

/* Il passaggio del mouse sospende lo scorrimento */
.slideshow-auto:hover .slideshow-slide {
  animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
  .slideshow-auto .slideshow-slide {
    animation: none;
    opacity: 0;
  }

  .slideshow-auto .slideshow-slide:first-child {
    opacity: 1;
  }
}

Il valore both per animation-fill-mode è essenziale. Durante il periodo di ritardo, prima che l'animazione di una slide parta, il browser applica lo stato del primo keyframe, cioè l'opacità zero. Senza questa impostazione, la seconda e la terza slide sarebbero visibili per i primi secondi, sovrapposte alla prima.

Le percentuali dei keyframe vanno ricalcolate se cambia il numero delle slide: la finestra di visibilità deve corrispondere a 100% / numero di slide, con un piccolo margine per le dissolvenze. Per quattro slide, ad esempio, la slide resterà piena dal 3% al 25% e tornerà trasparente al 28%. Questo è il principale limite della variante automatica: i keyframe non possono leggere le custom property per calcolare le proprie percentuali, quindi il numero di slide è di fatto cablato nell'animazione.

Va detto con chiarezza che uno slideshow che scorre da solo pone un problema di accessibilità: le linee guida WCAG chiedono che qualunque contenuto in movimento per più di cinque secondi possa essere messo in pausa, fermato o nascosto. La pausa al passaggio del mouse aiuta, ma non è utilizzabile da tastiera o su dispositivi touch. Per questo motivo la variante automatica in puro CSS è adatta soprattutto a contesti decorativi; quando lo slideshow trasmette contenuti importanti è preferibile un controllo esplicito di pausa, che realizzeremo nell'articolo dedicato a JavaScript.

Terza variante: lo scroll snapping

Esiste un terzo approccio, radicalmente diverso, che sfrutta lo scorrimento nativo del browser. Le slide vengono disposte in fila orizzontale dentro un contenitore scorrevole e le proprietà di scroll snapping fanno sì che lo scorrimento si fermi sempre esattamente su una slide.

<figure class="slideshow-scroller" aria-label="Galleria scorrevole">
  <ul class="slideshow-track">
    <li id="photo-1"><img src="images/mountains.jpg" alt="Catena montuosa all'alba"></li>
    <li id="photo-2"><img src="images/lake.jpg" alt="Lago alpino circondato da abeti" loading="lazy"></li>
    <li id="photo-3"><img src="images/coast.jpg" alt="Costa rocciosa al tramonto" loading="lazy"></li>
  </ul>
  <nav class="slideshow-links" aria-label="Seleziona immagine">
    <a href="#photo-1">1</a>
    <a href="#photo-2">2</a>
    <a href="#photo-3">3</a>
  </nav>
</figure>
.slideshow-track {
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
  overflow-x: auto;
  /* Lo scorrimento si ferma sempre all'inizio di una slide */
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
}

.slideshow-track li {
  flex: 0 0 100%;
  scroll-snap-align: start;
  /* Impedisce di "saltare" più slide con uno swipe veloce */
  scroll-snap-stop: always;
}

.slideshow-track img {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

.slideshow-links {
  display: flex;
  justify-content: center;
  gap: 0.5rem;
  padding: 0.75rem;
}

@media (prefers-reduced-motion: reduce) {
  .slideshow-track {
    scroll-behavior: auto;
  }
}

Questa variante ha pregi notevoli: lo swipe sui dispositivi touch funziona in modo nativo e fluido, lo scorrimento con trackpad e rotella è supportato senza codice aggiuntivo e i collegamenti agli id delle slide portano direttamente all'immagine desiderata. Il difetto è che il CSS da solo non può sapere quale slide è attualmente visibile, quindi gli indicatori non possono evidenziare la posizione corrente. Inoltre, i collegamenti con frammento modificano l'URL e possono far scorrere l'intera pagina per portare lo slideshow nella vista.

I browser più recenti stanno introducendo specifiche come gli pseudo-elementi ::scroll-marker e ::scroll-button(), che permettono di generare indicatori e pulsanti di scorrimento direttamente dal CSS, con tanto di stato attivo. Al momento il supporto non è ancora uniforme, ma vale la pena tenerle d'occhio: una volta consolidate, renderanno lo scroll snapping la soluzione più completa per gli slideshow senza JavaScript.

Confronto tra le tre tecniche

La variante con i radio button è la più completa: offre frecce, indicatori con stato attivo, navigazione circolare e navigazione da tastiera nativa, al prezzo di un markup un po' artificioso e di selettori da replicare per ogni slide. La variante automatica è la più semplice da integrare, ma non offre alcun controllo all'utente ed è vincolata a un numero di slide fisso nei keyframe. La variante con lo scroll snapping è la più naturale sui dispositivi touch, ma non conosce la slide corrente.

Nessuna delle tre può fare alcune cose che in un'applicazione reale diventano presto necessarie: generare dinamicamente le slide da un elenco di dati, combinare la riproduzione automatica con un pulsante di pausa accessibile, sospendere lo scorrimento quando la scheda del browser non è visibile, annunciare il cambio di immagine agli screen reader o emettere un evento quando la slide cambia. Sono esattamente i problemi che affronteremo nel prossimo articolo, in cui riprenderemo lo stesso layout e affideremo la logica a JavaScript.

Conclusioni

Uno slideshow in puro CSS non è soltanto un esercizio di stile. È una soluzione leggera, che funziona anche con JavaScript disabilitato, non introduce dipendenze e sfrutta comportamenti nativi del browser come la navigazione da tastiera dei gruppi di radio e lo scorrimento con snapping. Il principio che abbiamo applicato, cioè affidare lo stato a un elemento che lo gestisce nativamente e lasciare al CSS il compito di rappresentarlo, è utile ben oltre gli slideshow: tab, accordion e menu a comparsa possono essere costruiti allo stesso modo. Quando però le esigenze crescono, il passaggio a JavaScript diventa inevitabile, e lo vedremo nel prossimo articolo della serie.