Sistema di Toast Notification da Zero: JavaScript Vanilla

Sistema di Toast Notification da Zero: JavaScript Vanilla

Perché le Toast Notification nel 2026

Le toast notification sono uno degli elementi UI più usati e, paradossalmente, più spesso implementati male. Ogni framework ha la sua libreria — Toastify, react-hot-toast, Notistack — ma dipendere da un pacchetto esterno per un componente da 80 righe di codice è un costo che molti progetti non dovrebbero pagare. Peso del bundle, versioning, aggiornamenti di breaking change: tutto si accumula.

Nel 2026, con le API browser finalmente mature e il supporto alle CSS Custom Properties universale, costruire un sistema di toast da zero in JavaScript Vanilla è la scelta più professionale per chi vuole controllo totale. Non c'è nessuna astrazione tra te e il DOM, nessun overhead di re-render, nessun conflitto con il tuo CSS framework.

In questo articolo costruiremo passo per passo un sistema completo: quattro tipi di notifica (success, error, warning, info), animazioni in entrata e uscita, gestione della coda, posizionamento configurabile e accessibilità ARIA. Il risultato sarà uno snippet copia-incolla pronto per qualsiasi progetto, da un semplice sito WordPress a un'applicazione SaaS. Se vuoi capire come gestire anche gli fondamenti delle animazioni JavaScript per la UI, trovi una guida dedicata sul blog.

Architettura del Sistema: il Modulo ToastManager

Prima di scrivere una riga di codice, è utile definire l'architettura. Il sistema si compone di tre parti:

  • ToastManager — singleton che gestisce il container DOM e la coda delle notifiche
  • Toast — factory function che crea il singolo elemento notifica
  • CSS — variabili e animazioni separati dalla logica JS

La separazione è intenzionale: il CSS può essere sovrascritto senza toccare la logica, e la logica può essere testata in isolamento. Il pattern singleton per il manager garantisce che esista un unico container nel DOM, evitando duplicati anche se toast() viene chiamata da moduli diversi.

// toast-manager.js
class ToastManager {
  constructor() {
    this.container = null;
    this.queue = [];
    this.maxVisible = 5;
    this._init();
  }

  _init() {
    // Crea container se non esiste
    if (document.getElementById('toast-container')) {
      this.container = document.getElementById('toast-container');
      return;
    }
    this.container = document.createElement('div');
    this.container.id = 'toast-container';
    this.container.setAttribute('aria-live', 'polite');
    this.container.setAttribute('aria-atomic', 'false');
    this.container.setAttribute('role', 'status');
    document.body.appendChild(this.container);
  }

  show(message, type = 'info', options = {}) {
    const toast = createToast(message, type, options);
    this.container.appendChild(toast.element);

    // Forza reflow per triggerare la transizione CSS
    toast.element.offsetHeight;
    toast.element.classList.add('toast--visible');

    const duration = options.duration ?? 4000;
    if (duration > 0) {
      setTimeout(() => this.dismiss(toast), duration);
    }

    return toast;
  }

  dismiss(toast) {
    toast.element.classList.remove('toast--visible');
    toast.element.classList.add('toast--hiding');
    toast.element.addEventListener('transitionend', () => {
      toast.element.remove();
    }, { once: true });
  }
}

// Singleton
const toastManager = new ToastManager();

// API pubblica
export function toast(message, type, options) {
  return toastManager.show(message, type, options);
}

Il trucco del offsetHeight prima di aggiungere la classe toast--visible è fondamentale: senza di esso il browser ottimizza le operazioni DOM e salta la transizione CSS. Questo pattern è lo stesso che trovi nel codice di ogni libreria professionale.

La Factory Function createToast

La factory createToast costruisce il markup HTML del singolo toast e restituisce un oggetto con il reference all'elemento DOM. Ogni toast ha un'icona SVG inline (zero dipendenze), un messaggio, un pulsante di chiusura opzionale e gli attributi ARIA corretti.

// create-toast.js
const ICONS = {
  success: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>',
  error:   '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
  warning: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
  info:    '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
};

export function createToast(message, type = 'info', options = {}) {
  const el = document.createElement('div');
  el.className = `toast toast--${type}`;
  el.setAttribute('role', type === 'error' ? 'alert' : 'status');
  el.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');

  const iconHtml = ICONS[type] ?? ICONS.info;
  const closeBtn = options.dismissible !== false
    ? `<button class="toast__close" aria-label="Chiudi notifica">
         <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
           <line x1="18" y1="6" x2="6" y2="18"/>
           <line x1="6" y1="6" x2="18" y2="18"/>
         </svg>
       </button>`
    : '';

  el.innerHTML = `
    <span class="toast__icon">${iconHtml}</span>
    <span class="toast__message">${message}</span>
    ${closeBtn}
  `;

  // Gestione click sul pulsante di chiusura
  const btn = el.querySelector('.toast__close');
  if (btn) {
    btn.addEventListener('click', () => {
      el.dispatchEvent(new CustomEvent('toast:dismiss'));
    });
  }

  return { element: el, type, message };
}

Nota l'uso di role="alert" per gli errori: questo fa sì che gli screen reader annuncino il messaggio immediatamente, indipendentemente dal focus corrente. Per gli altri tipi usiamo role="status" che è più discreto. È la stessa logica che applica costruire interfacce accessibili con ARIA e microinterazioni: il tipo di contenuto determina il livello di urgenza.

CSS: Animazioni e Variabili

Il CSS è la parte più importante per la percezione della qualità. Un toast che appare di scatto sembra rotto; uno che scivola con una curva di easing fluida sembra professionale. Usiamo CSS Custom Properties per rendere tutto personalizzabile senza toccare il JavaScript.

/* toast.css */
:root {
  --toast-bg:         #1e293b;
  --toast-color:      #f1f5f9;
  --toast-radius:     10px;
  --toast-shadow:     0 8px 32px rgba(0,0,0,.35);
  --toast-min-width:  280px;
  --toast-max-width:  400px;
  --toast-gap:        12px;
  --toast-duration:   0.35s;
  --toast-easing:     cubic-bezier(0.34, 1.56, 0.64, 1);

  --toast-success:    #22c55e;
  --toast-error:      #ef4444;
  --toast-warning:    #f59e0b;
  --toast-info:       #06b6d4;
}

#toast-container {
  position: fixed;
  bottom: 24px;
  right: 24px;
  z-index: 9999;
  display: flex;
  flex-direction: column-reverse;
  gap: var(--toast-gap);
  pointer-events: none;
}

/* Posizionamento alternativo top-center */
#toast-container[data-position="top-center"] {
  top: 24px;
  bottom: auto;
  left: 50%;
  right: auto;
  transform: translateX(-50%);
  align-items: center;
  flex-direction: column;
}

.toast {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 14px 16px;
  min-width: var(--toast-min-width);
  max-width: var(--toast-max-width);
  background: var(--toast-bg);
  color: var(--toast-color);
  border-radius: var(--toast-radius);
  box-shadow: var(--toast-shadow);
  pointer-events: all;

  /* Stato iniziale (fuori schermo) */
  opacity: 0;
  transform: translateX(calc(100% + 24px));
  transition:
    opacity var(--toast-duration) var(--toast-easing),
    transform var(--toast-duration) var(--toast-easing);
}

.toast--visible {
  opacity: 1;
  transform: translateX(0);
}

.toast--hiding {
  opacity: 0;
  transform: translateX(calc(100% + 24px));
  pointer-events: none;
}

/* Tipo-specifici: bordo sinistro colorato */
.toast--success { border-left: 4px solid var(--toast-success); }
.toast--error   { border-left: 4px solid var(--toast-error); }
.toast--warning { border-left: 4px solid var(--toast-warning); }
.toast--info    { border-left: 4px solid var(--toast-info); }

/* Icona */
.toast__icon {
  flex-shrink: 0;
  display: flex;
  align-items: center;
}
.toast--success .toast__icon { color: var(--toast-success); }
.toast--error   .toast__icon { color: var(--toast-error); }
.toast--warning .toast__icon { color: var(--toast-warning); }
.toast--info    .toast__icon { color: var(--toast-info); }

.toast__message {
  flex: 1;
  font-size: 0.9rem;
  line-height: 1.4;
}

.toast__close {
  flex-shrink: 0;
  background: none;
  border: none;
  cursor: pointer;
  color: #64748b;
  padding: 2px;
  display: flex;
  align-items: center;
  border-radius: 4px;
  transition: color 0.15s, background 0.15s;
}
.toast__close:hover {
  color: var(--toast-color);
  background: rgba(255,255,255,.08);
}

/* Progress bar opzionale */
.toast--progress::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  height: 3px;
  width: 100%;
  background: currentColor;
  opacity: 0.4;
  border-radius: 0 0 var(--toast-radius) var(--toast-radius);
  animation: toast-progress linear forwards;
  animation-duration: inherit;
}

@keyframes toast-progress {
  from { width: 100%; }
  to   { width: 0%; }
}

/* Responsive */
@media (max-width: 480px) {
  #toast-container {
    right: 12px;
    left: 12px;
    bottom: 12px;
  }
  .toast {
    min-width: 0;
    max-width: 100%;
  }
}

Il valore cubic-bezier(0.34, 1.56, 0.64, 1) per --toast-easing produce un leggero overshoot (la notifica “rimbalza” leggermente): è la firma delle animazioni percepite come “vivaci” senza essere fastidiose. Se preferisci qualcosa di più sobrio, usa ease-out.

API Pubblica e Utilizzo

Con l'architettura definita, l'API pubblica è minimalista. Una singola funzione toast() con tre parametri: messaggio, tipo e opzioni. Nessuna configurazione obbligatoria, funziona out-of-the-box.

// Utilizzo base ─────────────────────────────────────────
import { toast } from './toast-manager.js';

// I quattro tipi di notifica
toast('Profilo salvato con successo!', 'success');
toast('Errore nel caricamento del file.', 'error');
toast('La sessione scadrà tra 5 minuti.', 'warning');
toast('Hai ricevuto un nuovo messaggio.', 'info');

// Con opzioni avanzate ──────────────────────────────────
toast('Download completato', 'success', {
  duration: 6000,       // ms prima della chiusura automatica (0 = no auto-close)
  dismissible: true,    // mostra pulsante X (default: true)
});

// Toast persistente (solo dismiss manuale)
const t = toast('Connessione persa. Riconnessione in corso…', 'error', {
  duration: 0,
  dismissible: true,
});

// Dismiss programmatico
setTimeout(() => toastManager.dismiss(t), 8000);

// Integrazione con fetch ────────────────────────────────
async function saveSettings(data) {
  try {
    const res = await fetch('/api/settings', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    toast('Impostazioni salvate!', 'success');
  } catch (err) {
    toast(`Impossibile salvare: ${err.message}`, 'error');
  }
}

// Integrazione con form HTML ────────────────────────────
document.querySelector('#contact-form').addEventListener('submit', async (e) => {
  e.preventDefault();
  const formData = new FormData(e.target);

  toast('Invio in corso…', 'info', { duration: 0 });

  // ... logica di submit ...

  toast('Messaggio inviato con successo!', 'success');
});

Questo pattern di integrazione con fetch è il caso d'uso più comune. Se vuoi approfondire la gestione degli errori fetch con retry e back-off esponenziale, ho scritto una guida dedicata con pattern più robusti per applicazioni in produzione.

Versione Senza Moduli ES6: Script Unico

Non tutti i progetti usano ES modules. Se stai lavorando su un tema WordPress o un sito senza bundler, ecco la versione IIFE che funziona con un semplice <script> tag. Lo snippet è completamente autonomo e non richiede nessun import.

(function () {
  'use strict';

  const ICONS = {
    success: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>',
    error:   '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
    warning: '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
    info:    '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
  };

  function getOrCreateContainer() {
    let el = document.getElementById('toast-container');
    if (!el) {
      el = document.createElement('div');
      el.id = 'toast-container';
      el.setAttribute('aria-live', 'polite');
      el.setAttribute('role', 'status');
      document.body.appendChild(el);
    }
    return el;
  }

  window.toast = function (message, type, options) {
    type = type || 'info';
    options = options || {};
    const container = getOrCreateContainer();

    const el = document.createElement('div');
    el.className = 'toast toast--' + type;
    el.setAttribute('role', type === 'error' ? 'alert' : 'status');
    el.innerHTML =
      '<span class="toast__icon">' + (ICONS[type] || ICONS.info) + '</span>' +
      '<span class="toast__message">' + message + '</span>' +
      (options.dismissible !== false
        ? '<button class="toast__close" aria-label="Chiudi">✕</button>'
        : '');

    container.appendChild(el);
    el.offsetHeight; // forza reflow
    el.classList.add('toast--visible');

    function dismiss() {
      el.classList.remove('toast--visible');
      el.classList.add('toast--hiding');
      el.addEventListener('transitionend', function () { el.remove(); }, { once: true });
    }

    var btn = el.querySelector('.toast__close');
    if (btn) btn.addEventListener('click', dismiss);

    var duration = options.duration !== undefined ? options.duration : 4000;
    if (duration > 0) setTimeout(dismiss, duration);
  };
})();

Questa versione espone window.toast globalmente. La puoi incollare direttamente in functions.php di un tema WordPress con wp_enqueue_script e funziona senza configurazione aggiuntiva. Ideale per chi sta costruendo componenti UI personalizzati senza dipendenze esterne, esattamente come si fa con le modali personalizzate in JavaScript da zero.

Test e Debug del Sistema

Un componente riusabile senza test è un debito tecnico. Ecco un set minimale di test con Vitest (zero configurazione, compatibile con ES modules) per verificare che il sistema funzioni correttamente:

// toast.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { toast } from './toast-manager.js';

// Setup DOM minimale (jsdom)
beforeEach(() => {
  document.body.innerHTML = '';
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
});

describe('toast()', () => {
  it('crea un elemento nel DOM', () => {
    toast('Ciao!', 'success');
    const el = document.querySelector('.toast--success');
    expect(el).not.toBeNull();
  });

  it('il messaggio è presente nel markup', () => {
    toast('Test message', 'info');
    const msg = document.querySelector('.toast__message');
    expect(msg.textContent).toBe('Test message');
  });

  it('aggiunge la classe visible dopo il reflow', () => {
    toast('Visibile', 'info');
    // offsetHeight viene chiamato -> simula reflow
    const el = document.querySelector('.toast');
    expect(el.classList.contains('toast--visible')).toBe(true);
  });

  it('rimuove il toast dopo duration ms', () => {
    toast('Temporaneo', 'success', { duration: 2000 });
    expect(document.querySelector('.toast')).not.toBeNull();
    vi.advanceTimersByTime(2001);
    expect(document.querySelector('.toast--visible')).toBeNull();
  });

  it('toast error usa role=alert', () => {
    toast('Errore critico', 'error');
    const el = document.querySelector('.toast--error');
    expect(el.getAttribute('role')).toBe('alert');
  });

  it('toast non-error usa role=status', () => {
    toast('Info', 'info');
    const el = document.querySelector('.toast--info');
    expect(el.getAttribute('role')).toBe('status');
  });
});

Questi sei test coprono i casi critici: presenza nel DOM, contenuto del messaggio, accessibilità ARIA e auto-dismiss. Se stai costruendo un'applicazione più complessa, considera di aggiungere test per la coda e il posizionamento. Per un'overview completa degli strumenti di testing frontend nel 2026, la guida su Jest, Vitest e Playwright è un ottimo punto di partenza.

FAQ e Domande Frequenti

Quante toast si possono mostrare contemporaneamente?

Non c'è un limite hard nel codice base presentato, ma l'esperienza utente degrada oltre le 3-4 notifiche simultanee. La best practice è implementare una coda: se ci sono già N toast visibili, le nuove vengono accodate e mostrate man mano che le precedenti vengono chiuse. Nel ToastManager puoi aggiungere la logica di coda verificando this.container.children.length prima di ogni show(). Il valore consigliato per maxVisible è 3.

Come cambio la posizione del container (es. top-left)?

Basta aggiungere un attributo data-position al container e gestirlo nel CSS. Le posizioni standard sono: top-right, top-center, top-left, bottom-right (default), bottom-center, bottom-left. Per la posizione top-center ricorda di cambiare anche la direzione dell'animazione in entrata: invece di translateX(100%) usa translateY(-100%). La variabile CSS --toast-easing rimane la stessa per coerenza.

Funziona con React o Vue senza modifiche?

Sì, con qualche accorgimento. In React puoi wrappare la funzione toast() in un hook custom useToast() che chiama l'imperativa API Vanilla al suo interno. In Vue puoi esporla come plugin globale. Il vantaggio di questo approccio rispetto alle librerie specifiche per framework è che il tuo sistema di toast sopravvive ai cambi di tecnologia: se migri da React a Svelte, non devi riscrivere nulla. Il DOM viene gestito direttamente, indipendentemente dal Virtual DOM del framework.

Come gestire i toast su mobile con swipe-to-dismiss?

La gestione del touch richiede i pointer events. Ascolta pointerdown, pointermove e pointerup sul singolo toast. Calcola il delta X tra start e end: se supera i 50px nella direzione corretta, aggiungi la classe toast--hiding con transform: translateX(120%). Usa el.setPointerCapture(e.pointerId) per catturare il pointer anche quando esce dall'elemento. Il threshold di 50px è abbastanza ampio da evitare dismiss accidentali ma piccolo abbastanza da sembrare responsivo al tocco.

Conclusione

Un sistema di toast notification da zero in JavaScript Vanilla non è solo un esercizio accademico: è la scelta giusta ogni volta che vuoi controllo totale su stile, comportamento e accessibilità senza appesantire il bundle con dipendenze che fanno il 90% delle cose di cui non hai bisogno. Con meno di 150 righe tra JS e CSS hai un componente production-ready, accessibile, animato e testabile.

Il codice presentato in questo articolo è deliberatamente minimalista: è una base solida, non una libreria completa. Aggiungere feature (progress bar, raggruppamento, temi dark/light) è semplice una volta che l'architettura è chiara. Parti da qui, adatta alle tue esigenze, e smetti di installare pacchetti per ogni piccolo componente UI.

Suggerimenti e Risorse

🔧 Snippet pronto: copia il file CSS e il file JS (versione IIFE) nella tua cartella /assets/, includi il CSS nel <head> e lo script prima di </body>. Chiama window.toast('Messaggio', 'success') da qualsiasi punto del tuo codice. Zero dipendenze, zero configurazione.

💡 Pro tip: per un'esperienza utente ancora più raffinata, aggiungi prefers-reduced-motion nel tuo CSS: @media (prefers-reduced-motion: reduce) { .toast { transition: opacity 0.1s; transform: none; } }. Gli utenti che hanno disabilitato le animazioni vedranno un semplice fade invece dello slide, rispettando le loro preferenze di accessibilità.

🎯 Prossimo step: una volta che hai il sistema di toast, il pattern naturale successivo è un sistema di modal/dialog con la stessa architettura (singleton manager + factory function). Puoi leggere come creare una modale personalizzata in JavaScript per applicare gli stessi principi a un componente più complesso.

Condividi

Articoli Recenti

Categorie popolari