Creare uno slideshow di immagini con JavaScript

Creare uno slideshow di immagini con JavaScript

Nel primo articolo di questa serie abbiamo costruito uno slideshow utilizzando soltanto HTML e CSS, e ne abbiamo individuato i limiti: lo stato era codificato nel markup, il numero di slide era cablato nei selettori e nelle animazioni, e non era possibile offrire una riproduzione automatica con un controllo di pausa accessibile. In questo articolo affidiamo la logica a JavaScript, senza librerie né framework, e costruiamo una classe Slideshow riutilizzabile, configurabile tramite attributi data-*, che gestisce navigazione, indicatori generati dinamicamente, riproduzione automatica, tastiera, swipe, accessibilità ed eventi personalizzati.

La presentazione rimane affidata al CSS: JavaScript si limita a mantenere lo stato e a riflettere tale stato nel DOM tramite classi e attributi. Questa separazione delle responsabilità è la stessa che ritroveremo, in forme diverse, nei framework trattati negli articoli successivi.

Il markup

Il markup è più semplice di quello della versione in puro CSS, perché non dobbiamo più simulare lo stato con i radio button. Contiene le slide, i pulsanti di navigazione, un pulsante di pausa, un contenitore vuoto per gli indicatori e una regione invisibile per gli annunci agli screen reader.

<figure class="slideshow" data-slideshow data-autoplay="true" data-interval="5000" data-loop="true"
        aria-roledescription="carousel" aria-label="Galleria di viaggio">
  <ul class="slideshow-slides">
    <li class="slideshow-slide" aria-roledescription="slide" aria-label="1 di 3">
      <img src="images/mountains.jpg" alt="Catena montuosa all'alba" width="1600" height="900">
      <p class="slideshow-caption">Le montagne all'alba</p>
    </li>
    <li class="slideshow-slide" aria-roledescription="slide" aria-label="2 di 3">
      <img src="images/lake.jpg" alt="Lago alpino circondato da abeti" width="1600" height="900" decoding="async">
      <p class="slideshow-caption">Un lago alpino</p>
    </li>
    <li class="slideshow-slide" aria-roledescription="slide" aria-label="3 di 3">
      <img src="images/coast.jpg" alt="Costa rocciosa al tramonto" width="1600" height="900" decoding="async">
      <p class="slideshow-caption">La costa al tramonto</p>
    </li>
  </ul>

  <button type="button" class="slideshow-prev" aria-label="Immagine precedente">&#8249;</button>
  <button type="button" class="slideshow-next" aria-label="Immagine successiva">&#8250;</button>
  <button type="button" class="slideshow-toggle" aria-pressed="false">Pausa</button>

  <!-- Gli indicatori vengono generati da JavaScript -->
  <nav class="slideshow-dots" aria-label="Seleziona immagine"></nav>

  <!-- Regione per gli annunci agli screen reader -->
  <p class="slideshow-status visually-hidden" aria-live="polite"></p>
</figure>

Gli attributi aria-roledescription seguono il pattern "carousel" delle WAI-ARIA Authoring Practices: comunicano agli screen reader che il contenitore è un carosello e che ogni elemento della lista è una slide, con un'etichetta che ne indica la posizione. Gli attributi data-autoplay, data-interval e data-loop permettono di configurare ogni istanza direttamente dall'HTML, senza toccare il codice JavaScript.

I pulsanti sono veri elementi <button> con type="button". Questo garantisce gratuitamente focus da tastiera, attivazione con Invio e barra spaziatrice e un ruolo corretto per le tecnologie assistive. L'attributo type evita che, se lo slideshow si trovasse all'interno di un form, un clic sulle frecce lo invii.

Il CSS

Il foglio di stile riprende il layout del primo articolo: le slide sono sovrapposte e la slide attiva, identificata dalla classe is-active, è l'unica visibile. Una regola aggiuntiva gestisce il caso in cui JavaScript non sia disponibile.

.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;
  /* Lo swipe orizzontale è gestito da noi, quello verticale dal browser */
  touch-action: pan-y;
}

.slideshow-slide {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.6s ease;
}

.slideshow-slide.is-active,
.slideshow:not(.is-ready) .slideshow-slide:first-child {
  opacity: 1;
}

.slideshow-slide img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  user-select: none;
  -webkit-user-drag: 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));
}

/* Senza JavaScript i controlli non servono */
.slideshow:not(.is-ready) :is(.slideshow-prev, .slideshow-next, .slideshow-toggle, .slideshow-dots) {
  display: none;
}

.slideshow-prev,
.slideshow-next {
  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-prev {
  left: 1rem;
}

.slideshow-next {
  right: 1rem;
}

.slideshow-prev:disabled,
.slideshow-next:disabled {
  opacity: 0.3;
  cursor: default;
}

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

@media (prefers-reduced-motion: reduce) {
  .slideshow-slide {
    transition: none;
  }
}

La classe is-ready viene aggiunta dallo script al termine dell'inizializzazione. Finché non è presente, il CSS mostra la prima slide e nasconde i controlli, che senza JavaScript sarebbero inutili. È un esempio di progressive enhancement: la pagina offre un contenuto sensato in ogni condizione e si arricchisce quando lo script è disponibile.

Lo stato attivo degli indicatori non è affidato a una classe, ma all'attributo aria-current. Selezionare in base a un attributo ARIA ha un vantaggio concreto: lo stile e l'informazione per le tecnologie assistive non possono divergere, perché sono la stessa cosa.

La struttura della classe

Organizziamo la logica in una classe. Ogni istanza gestisce un singolo slideshow, così nella stessa pagina possono convivere più slideshow indipendenti. Le opzioni predefinite sono dichiarate in un campo statico e vengono unite, in ordine di priorità crescente, con quelle lette dagli attributi data-* e con quelle passate al costruttore.

'use strict';

class Slideshow {
  static defaults = {
    autoplay: true,
    interval: 5000,
    loop: true,
    swipeThreshold: 50
  };

  constructor(root, options = {}) {
    if (!(root instanceof HTMLElement)) {
      throw new TypeError('Slideshow: root must be an HTMLElement');
    }

    this.root = root;
    this.options = { ...Slideshow.defaults, ...this.readDataOptions(), ...options };

    // Riferimenti agli elementi del DOM
    this.track = root.querySelector('.slideshow-slides');
    this.slides = Array.from(root.querySelectorAll('.slideshow-slide'));
    this.prevButton = root.querySelector('.slideshow-prev');
    this.nextButton = root.querySelector('.slideshow-next');
    this.toggleButton = root.querySelector('.slideshow-toggle');
    this.dotsContainer = root.querySelector('.slideshow-dots');
    this.status = root.querySelector('.slideshow-status');

    // Stato interno
    this.currentIndex = 0;
    this.dots = [];
    this.timerId = null;
    this.isPaused = false;
    this.isInteracting = false;
    this.pointerStartX = null;
    this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
    this.abortController = new AbortController();

    if (this.slides.length > 0) {
      this.init();
    }
  }
}

Lo stato dello slideshow si riduce a poche variabili: l'indice della slide corrente, l'identificatore del timer della riproduzione automatica e due flag. Il flag isPaused rappresenta la pausa esplicita richiesta dall'utente con il pulsante, mentre isInteracting rappresenta una pausa temporanea, attiva finché il puntatore o il focus si trovano sullo slideshow. Tenere distinti i due concetti è importante: quando il puntatore esce, la riproduzione deve riprendere soltanto se l'utente non l'aveva fermata esplicitamente.

L'AbortController merita una spiegazione a parte: lo useremo per registrare tutti i listener in modo che possano essere rimossi con una sola chiamata, quando lo slideshow verrà distrutto.

Leggere la configurazione dagli attributi

La proprietà dataset espone gli attributi data-* come stringhe. Il metodo readDataOptions le converte nei tipi corretti e ignora i valori assenti o non validi, in modo che non sovrascrivano i default.

readDataOptions() {
  const { autoplay, interval, loop } = this.root.dataset;
  const result = {};

  if (autoplay !== undefined) {
    result.autoplay = autoplay !== 'false';
  }
  if (interval !== undefined && !Number.isNaN(Number(interval))) {
    // Un intervallo troppo breve renderebbe lo slideshow inutilizzabile
    result.interval = Math.max(1000, Number(interval));
  }
  if (loop !== undefined) {
    result.loop = loop !== 'false';
  }

  return result;
}

Il confronto con la stringa 'false' fa sì che un attributo presente senza valore, come data-autoplay, venga interpretato come vero, coerentemente con il funzionamento degli attributi booleani in HTML.

L'inizializzazione

Il metodo init orchestra la preparazione dello slideshow: genera gli indicatori, registra i listener, mostra la prima slide, segnala che il componente è pronto e avvia la riproduzione automatica.

init() {
  this.createDots();
  this.bindEvents();
  this.goTo(0, { announce: false, force: true });
  this.root.classList.add('is-ready');
  this.startAutoplay();
}

createDots() {
  if (!this.dotsContainer) {
    return;
  }

  this.dotsContainer.replaceChildren();
  this.dots = this.slides.map((slide, index) => {
    const dot = document.createElement('button');
    dot.type = 'button';
    dot.className = 'slideshow-dot';
    dot.setAttribute('aria-label', `Vai all'immagine ${index + 1}`);
    dot.addEventListener('click', () => this.userGoTo(index), {
      signal: this.abortController.signal
    });
    this.dotsContainer.append(dot);
    return dot;
  });
}

Gli indicatori vengono generati a partire dalle slide presenti nel markup: aggiungere o togliere un'immagine non richiede più di modificare l'HTML. Il metodo replaceChildren senza argomenti svuota il contenitore, rendendo l'operazione sicura anche se venisse ripetuta.

Il metodo centrale: goTo

Tutte le operazioni di navigazione confluiscono in un unico metodo, che riceve l'indice desiderato, lo normalizza e aggiorna il DOM. Centralizzare la transizione di stato in un solo punto è ciò che rende il codice affidabile: frecce, indicatori, tastiera, swipe e timer non possono mai lasciare lo slideshow in uno stato incoerente, perché passano tutti dallo stesso metodo.

normalizeIndex(index) {
  const total = this.slides.length;

  if (this.options.loop) {
    // Il doppio modulo gestisce correttamente anche gli indici negativi
    return ((index % total) + total) % total;
  }
  return Math.min(Math.max(index, 0), total - 1);
}

goTo(index, { announce = true, force = false } = {}) {
  const target = this.normalizeIndex(index);
  const previousIndex = this.currentIndex;

  if (target === previousIndex && !force) {
    return;
  }

  this.currentIndex = target;

  this.slides.forEach((slide, i) => {
    const isActive = i === target;
    slide.classList.toggle('is-active', isActive);
    slide.setAttribute('aria-hidden', String(!isActive));
    // inert impedisce il focus sugli elementi delle slide nascoste
    slide.inert = !isActive;
  });

  this.dots.forEach((dot, i) => {
    if (i === target) {
      dot.setAttribute('aria-current', 'true');
    } else {
      dot.removeAttribute('aria-current');
    }
  });

  this.updateButtons();

  if (announce && this.status) {
    this.status.textContent = `Immagine ${target + 1} di ${this.slides.length}`;
  }

  if (target !== previousIndex) {
    this.root.dispatchEvent(new CustomEvent('slidechange', {
      bubbles: true,
      detail: { index: target, previousIndex }
    }));
  }
}

updateButtons() {
  if (this.options.loop) {
    return;
  }
  // Senza navigazione circolare, le frecce agli estremi si disattivano
  if (this.prevButton) {
    this.prevButton.disabled = this.currentIndex === 0;
  }
  if (this.nextButton) {
    this.nextButton.disabled = this.currentIndex === this.slides.length - 1;
  }
}

La normalizzazione dell'indice merita attenzione. In JavaScript l'operatore % restituisce un risultato con lo stesso segno del dividendo, quindi -1 % 3 vale -1 e non 2. L'espressione ((index % total) + total) % total riporta qualunque intero nell'intervallo valido, ed è ciò che rende possibile la navigazione circolare all'indietro dalla prima slide.

L'attributo inert è uno strumento potente e spesso trascurato. Un elemento inerte e tutti i suoi discendenti non possono ricevere il focus né eventi del puntatore, e vengono ignorati dalle tecnologie assistive. Se una slide contenesse un collegamento, senza inert l'utente da tastiera potrebbe raggiungere con Tab un link invisibile in una slide nascosta.

Infine, il metodo emette un CustomEvent chiamato slidechange. Il codice esterno può ascoltarlo per reagire ai cambi di immagine, ad esempio per inviare un evento di analytics o sincronizzare un altro componente, senza dover conoscere il funzionamento interno della classe.

La navigazione dell'utente

Distinguiamo la navigazione automatica da quella avviata dall'utente. Quando l'utente cambia slide, il timer della riproduzione automatica deve ripartire da zero: sarebbe fastidioso scegliere un'immagine e vederla sostituita un istante dopo perché il timer stava per scadere.

userGoTo(index) {
  this.goTo(index);
  this.restartAutoplay();
}

next() {
  this.userGoTo(this.currentIndex + 1);
}

prev() {
  this.userGoTo(this.currentIndex - 1);
}

La riproduzione automatica

La riproduzione automatica è governata da un'unica condizione, calcolata dal metodo canAutoplay, che tiene conto delle opzioni, della pausa esplicita, dell'interazione in corso, delle preferenze dell'utente sul movimento e della visibilità della pagina.

canAutoplay() {
  return this.options.autoplay
    && !this.isPaused
    && !this.isInteracting
    && !this.reducedMotion.matches
    && document.visibilityState === 'visible'
    && this.slides.length > 1;
}

startAutoplay() {
  this.stopAutoplay();

  if (!this.canAutoplay()) {
    return;
  }

  this.timerId = window.setInterval(() => {
    const isLast = this.currentIndex === this.slides.length - 1;

    if (!this.options.loop && isLast) {
      // Senza loop la riproduzione si ferma sull'ultima immagine
      this.stopAutoplay();
      return;
    }
    // Il cambio automatico non viene annunciato, per non disturbare
    this.goTo(this.currentIndex + 1, { announce: false });
  }, this.options.interval);
}

stopAutoplay() {
  if (this.timerId !== null) {
    window.clearInterval(this.timerId);
    this.timerId = null;
  }
}

restartAutoplay() {
  this.startAutoplay();
}

togglePause() {
  this.isPaused = !this.isPaused;

  if (this.toggleButton) {
    this.toggleButton.setAttribute('aria-pressed', String(this.isPaused));
    this.toggleButton.textContent = this.isPaused ? 'Riprendi' : 'Pausa';
  }

  this.startAutoplay();
}

Il metodo startAutoplay chiama sempre stopAutoplay come prima cosa. In questo modo non può mai esistere più di un timer attivo, un errore classico che fa accelerare lo slideshow a ogni ripresa. Inoltre, poiché startAutoplay verifica la condizione completa, possiamo chiamarlo ogni volta che uno qualunque dei fattori cambia, senza preoccuparci di quale sia: sarà lui a decidere se avviare il timer o no.

I cambi automatici non vengono annunciati nella regione aria-live. Un annuncio ogni cinque secondi renderebbe la pagina inutilizzabile per chi usa uno screen reader, perché interromperebbe continuamente la lettura. Gli annunci sono riservati alle azioni esplicite dell'utente, che ha bisogno di una conferma dell'effetto prodotto.

Il pulsante di pausa usa aria-pressed per comunicare il proprio stato. È un pulsante toggle: gli screen reader lo annunciano come "premuto" o "non premuto", mentre il testo visibile cambia tra "Pausa" e "Riprendi". Questo controllo soddisfa il requisito WCAG che impone di poter fermare i contenuti in movimento.

La registrazione degli eventi

Il metodo bindEvents collega tutte le interazioni. Ogni listener riceve il signal dell'AbortController, così la pulizia sarà banale.

bindEvents() {
  const { signal } = this.abortController;

  this.prevButton?.addEventListener('click', () => this.prev(), { signal });
  this.nextButton?.addEventListener('click', () => this.next(), { signal });
  this.toggleButton?.addEventListener('click', () => this.togglePause(), { signal });

  // Pausa temporanea durante l'interazione
  this.root.addEventListener('mouseenter', () => this.setInteracting(true), { signal });
  this.root.addEventListener('mouseleave', () => this.setInteracting(false), { signal });
  this.root.addEventListener('focusin', () => this.setInteracting(true), { signal });
  this.root.addEventListener('focusout', (event) => {
    // Il focus si è spostato fuori dallo slideshow?
    if (!this.root.contains(event.relatedTarget)) {
      this.setInteracting(false);
    }
  }, { signal });

  this.root.addEventListener('keydown', (event) => this.handleKeydown(event), { signal });

  // Swipe con i pointer events
  this.track?.addEventListener('pointerdown', (event) => this.handlePointerDown(event), { signal });
  this.track?.addEventListener('pointerup', (event) => this.handlePointerUp(event), { signal });
  this.track?.addEventListener('pointercancel', () => { this.pointerStartX = null; }, { signal });

  // Nessuno scorrimento automatico se la scheda non è visibile
  document.addEventListener('visibilitychange', () => this.startAutoplay(), { signal });
  this.reducedMotion.addEventListener('change', () => this.startAutoplay(), { signal });
}

setInteracting(value) {
  this.isInteracting = value;
  this.startAutoplay();
}

L'evento focusout si propaga verso l'alto, a differenza di blur, ed è quindi adatto a essere ascoltato sul contenitore. Viene però emesso anche quando il focus si sposta da un pulsante dello slideshow a un altro; controllando la proprietà relatedTarget, che indica l'elemento che sta ricevendo il focus, riprendiamo la riproduzione solo quando il focus esce davvero dal componente.

Il listener su visibilitychange risolve un problema sottile. Quando la scheda del browser è in secondo piano, i timer vengono rallentati ma continuano a scattare: tornando sulla pagina, l'utente si troverebbe in un punto casuale della sequenza. Fermare la riproduzione quando la pagina non è visibile evita il problema e risparmia anche risorse.

La tastiera

Le frecce sinistra e destra spostano lo slideshow, mentre i tasti Home e Fine portano alla prima e all'ultima immagine. Il listener è registrato sul contenitore, quindi funziona qualunque elemento interno abbia il focus.

handleKeydown(event) {
  const actions = {
    ArrowLeft: () => this.prev(),
    ArrowRight: () => this.next(),
    Home: () => this.userGoTo(0),
    End: () => this.userGoTo(this.slides.length - 1)
  };

  const action = actions[event.key];
  if (!action) {
    return;
  }

  // Evita lo scorrimento della pagina con Home e Fine
  event.preventDefault();
  action();
}

La mappa da tasto ad azione è più leggibile di una catena di condizioni ed è facilmente estendibile. La chiamata a preventDefault avviene soltanto per i tasti gestiti, così il comportamento predefinito di tutti gli altri tasti, come Tab, rimane intatto.

Lo swipe

I Pointer Events unificano mouse, penna e tocco in un'unica API. Per riconoscere uno swipe basta registrare la coordinata orizzontale alla pressione e confrontarla con quella al rilascio: se lo spostamento supera una soglia, cambiamo slide nella direzione corrispondente.

handlePointerDown(event) {
  // Consideriamo solo il pulsante principale o il primo dito
  if (!event.isPrimary || event.button !== 0) {
    return;
  }
  this.pointerStartX = event.clientX;
}

handlePointerUp(event) {
  if (this.pointerStartX === null) {
    return;
  }

  const deltaX = event.clientX - this.pointerStartX;
  this.pointerStartX = null;

  if (Math.abs(deltaX) < this.options.swipeThreshold) {
    return;
  }
  // Trascinando verso sinistra si avanza, verso destra si torna indietro
  if (deltaX < 0) {
    this.next();
  } else {
    this.prev();
  }
}

La proprietà CSS touch-action: pan-y impostata sulla lista delle slide è indispensabile: dice al browser che lo scorrimento verticale deve continuare a funzionare normalmente, mentre i gesti orizzontali vengono lasciati al nostro codice. Senza di essa, su molti dispositivi mobili il browser intercetterebbe il gesto e invierebbe un pointercancel invece di un pointerup.

La distruzione dell'istanza

In un'applicazione in cui il contenuto della pagina cambia dinamicamente, uno slideshow rimosso dal DOM deve liberare le proprie risorse. Grazie all'AbortController, il metodo destroy è molto breve.

destroy() {
  this.stopAutoplay();
  // Rimuove in un colpo solo tutti i listener registrati con il signal
  this.abortController.abort();
  this.dotsContainer?.replaceChildren();
  this.root.classList.remove('is-ready');
  this.slides.forEach((slide) => {
    slide.classList.remove('is-active');
    slide.removeAttribute('aria-hidden');
    slide.inert = false;
  });
}

Senza questo meccanismo avremmo dovuto conservare un riferimento a ogni funzione registrata per poterla passare a removeEventListener. Il listener su document per visibilitychange è il caso più insidioso: se non venisse rimosso, manterrebbe in vita l'istanza anche dopo la rimozione dello slideshow dal DOM, generando un memory leak.

L'avvio

Per inizializzare tutti gli slideshow della pagina basta cercare gli elementi con l'attributo data-slideshow e creare un'istanza per ciascuno. Conserviamo le istanze in una WeakMap, che associa ogni elemento alla propria istanza senza impedirne la garbage collection.

const slideshowInstances = new WeakMap();

document.addEventListener('DOMContentLoaded', () => {
  document.querySelectorAll('[data-slideshow]').forEach((root) => {
    slideshowInstances.set(root, new Slideshow(root));
  });
});

// Esempio: reagire al cambio di slide da codice esterno
document.addEventListener('slidechange', (event) => {
  const { index, previousIndex } = event.detail;
  console.log(`Slide cambiata: da ${previousIndex + 1} a ${index + 1}`);
});

Le opzioni possono anche essere passate direttamente al costruttore, e in quel caso prevalgono sugli attributi data-*. Ad esempio, new Slideshow(element, { interval: 3000, loop: false }) crea uno slideshow più rapido che si ferma all'ultima immagine.

Conclusioni

Con circa duecento righe di JavaScript abbiamo ottenuto uno slideshow completo: configurabile dall'HTML, accessibile da tastiera e da screen reader, rispettoso delle preferenze di movimento, utilizzabile con lo swipe, capace di comunicare con il resto della pagina tramite un evento personalizzato e di liberare le proprie risorse quando non serve più. Le scelte architetturali fondamentali sono due: tutte le modifiche di stato passano da un unico metodo, goTo, e la decisione di avviare o fermare il timer dipende da un'unica condizione, canAutoplay, ricalcolata ogni volta che qualcosa cambia.

Il limite di questo approccio è che sincronizziamo a mano lo stato e il DOM: ogni volta che l'indice cambia, dobbiamo ricordarci di aggiornare classi, attributi, indicatori e pulsanti. Nei prossimi articoli vedremo come Vue.js, React e Angular eliminano questo lavoro, permettendoci di descrivere in modo dichiarativo come l'interfaccia deve apparire in funzione dello stato.