22 settembre 2026

Swipe to remove sul web moderno: un’implementazione fluida con Scroll Snap, Intersection Observer e Web Animations

Come costruire un gesto “swipe per rimuovere” stabile, performante e cross‑browser senza finire in codice fragile e animazioni a scatti.

Implementare lo swipe-to-remove sul web può diventare rapidamente un groviglio di gesture, calcoli e animazioni poco fluide. In questo articolo vediamo un approccio moderno basato su tecnologie native: CSS Scroll Snap per la meccanica del gesto, Intersection Observer per determinare la soglia di “rimozione”, e Web Animations per transizioni coerenti e performanti. Con note pratiche su compatibilità e fallback.

Costruire un gesto di swipe per rimuovere (tipico delle app mobile) in un’interfaccia web è uno di quei compiti che sembrano semplici finché non ci sbatti contro: gestione del touch, soglie, inertia, conflitti con lo scroll, animazioni che “saltano” e mille edge case.

Un approccio più solido consiste nel smettere di “simulare” la fisica a mano e appoggiarsi il più possibile a primitive native della piattaforma. Una combinazione particolarmente efficace è:

  • CSS Scroll Snap: per rendere naturale il gesto di trascinamento e l’aggancio a posizioni predefinite.
  • Intersection Observer: per capire in modo affidabile quando l’utente ha “superato la soglia” di rimozione.
  • Web Animations API: per animare in modo fluido la rimozione (collasso, fade, slide out) senza layout thrashing.

Di seguito un modello mentale e una struttura che puoi adattare a liste, inbox, carrelli, notifiche.


1) Il pattern: uno “snap” tra stato normale e stato rimuovi

L’idea è costruire ogni riga come un piccolo contenitore scrollabile orizzontalmente con due “pagine”:

  1. Stato normale: la card/item principale.
  2. Stato azione: area a destra (o sinistra) che rappresenta l’azione di rimozione.

Con scroll-snap l’utente trascina, il browser gestisce inerzia e decelerazione, e l’item “aggancia” in modo prevedibile.

Struttura HTML indicativa

<li class="row">
  <div class="swipe">
    <div class="pane pane--content">
      <!-- contenuto dell’item -->
      <h3>Messaggio</h3>
      <p>Anteprima...</p>
    </div>

    <button class="pane pane--remove" type="button" aria-label="Rimuovi">
      Rimuovi
    </button>
  </div>
</li>

CSS: scroll-snap “a pagine”

.row {
  list-style: none;
}

.swipe {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;

  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}

.swipe::-webkit-scrollbar { display: none; }

.pane {
  scroll-snap-align: start;
}

.pane--remove {
  display: grid;
  place-items: center;
  background: #d92c2c;
  color: white;
  border: 0;
}

Con questa base ottieni già una gesture utilizzabile: trascinamento orizzontale, snapping e un’area “Rimuovi” raggiungibile.


2) La soglia di rimozione con Intersection Observer

Il passo successivo è determinare quando considerare l’azione “confermata”. Puoi farlo in due modi tipici:

  • Swipe fino allo snap dell’area remove → rimuovi automaticamente.
  • Swipe parziale → mostra l’azione e richiedi un tap su “Rimuovi”.

Per evitare calcoli manuali basati su scrollLeft e dimensioni (fragili con font, zoom, RTL, layout responsive), puoi usare un sentinel osservato.

Esempio: inserisci un piccolo elemento “marker” all’inizio della seconda pagina (remove) e osserva quando entra sufficientemente in view.

<div class="pane pane--remove">
  <span class="sentinel" aria-hidden="true"></span>
  Rimuovi
</div>
.sentinel {
  width: 1px;
  height: 1px;
}
const swipeEls = document.querySelectorAll('.swipe');

swipeEls.forEach((swipeEl) => {
  const sentinel = swipeEl.querySelector('.pane--remove .sentinel');
  if (!sentinel) return;

  const io = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        // entry.isIntersecting + threshold gestiscono la “soglia”
        if (entry.isIntersecting && entry.intersectionRatio >= 0.6) {
          swipeEl.dispatchEvent(new CustomEvent('swipe:readyToRemove', { bubbles: true }));
        }
      }
    },
    {
      root: swipeEl,
      threshold: [0.6],
    }
  );

  io.observe(sentinel);
});

Questa soluzione è robusta perché:

  • non dipende da frame-by-frame JS durante il drag;
  • delega al browser la parte complessa (scroll + inertia);
  • rende semplice cambiare la soglia (es. 0.5, 0.7) senza rifare matematica.

3) Rimozione fluida con Web Animations API (senza reflow a raffica)

Quando decidi di rimuovere l’item, l’effetto “giusto” non è solo farlo sparire, ma:

  1. dare feedback (fade/slide);
  2. collassare lo spazio nella lista in modo elegante.

Una sequenza comune:

  • anima l’opacità e un leggero translate;
  • poi anima l’altezza da offsetHeight a 0.
document.addEventListener('swipe:readyToRemove', async (e) => {
  const swipeEl = e.target.closest('.swipe');
  const row = e.target.closest('.row');
  if (!row || !swipeEl) return;

  // evita doppi trigger
  if (row.dataset.removing === 'true') return;
  row.dataset.removing = 'true';

  // 1) feedback visivo
  await row.animate(
    [
      { opacity: 1, transform: 'translateX(0px)' },
      { opacity: 0, transform: 'translateX(-8px)' }
    ],
    { duration: 160, easing: 'ease-out', fill: 'forwards' }
  ).finished;

  // 2) collasso
  const h = row.offsetHeight;
  row.style.height = `${h}px`;
  row.style.overflow = 'clip';

  await row.animate(
    [ { height: `${h}px` }, { height: '0px' } ],
    { duration: 180, easing: 'ease-in', fill: 'forwards' }
  ).finished;

  row.remove();
});

Nota: per accessibilità e controllo, spesso conviene non rimuovere automaticamente al primo superamento soglia, ma richiedere conferma (tap su bottone) o offrire undo. Il pattern sopra è un buon “motore” su cui costruire queste scelte di UX.


4) Compatibilità e fallback: cosa fare quando manca una feature

Le primitive citate sono in gran parte ben supportate. Dove tipicamente sorgono problemi:

  • comportamenti specifici legati allo scroll (es. particolari proprietà CSS meno diffuse);
  • differenze di feeling tra browser su inerzia e snapping.

Strategia pratica:

  1. Progressive enhancement: se scroll-snap-type non è disponibile, mantieni un’interazione base (tap su “Rimuovi”).
  2. Feature detection in CSS e JS:
    • CSS: @supports (scroll-snap-type: x mandatory) { ... }
    • JS: controlli su API ('IntersectionObserver' in window, 'animate' in Element.prototype)
  3. Alternative semplici: se manca Web Animations, passa a transizioni CSS; se manca Intersection Observer, degrada a controllo scrollLeft su scrollend (o un debounce su scroll).

L’obiettivo non è inseguire la perfezione identica ovunque, ma garantire:

  • gesto naturale dove supportato;
  • comportamento coerente (niente stati bloccati);
  • fallback funzionale.

Sintesi: meno “gesture logic”, più piattaforma

Uno swipe-to-remove ben fatto non nasce da decine di listener touch e calcoli per-frame. La via moderna è comporre primitive native:

  • Scroll Snap per rendere lo swipe prevedibile e fisico “gratis”;
  • Intersection Observer per decidere soglie e stati in modo affidabile;
  • Web Animations per una rimozione fluida senza scatti.

Il risultato è un’interazione più stabile, più semplice da mantenere e più facile da far evolvere (undo, multi-azioni, layout diversi) senza trasformare ogni lista in un mini motore di gesture.