Fai scorrere per rimuovere

I pattern di scorrimento per la rimozione sono comuni nelle applicazioni mobile, ma possono essere difficili da implementare in modo pulito sul web. Utilizzando l'allineamento dello scorrimento CSS, puoi creare un'interazione di scorrimento fluida e nativa che si aggancia direttamente al motore di scorrimento del browser. In questo modo, si garantiscono prestazioni elevate e un movimento basato sulla fisica senza la necessità di una libreria di gesti JavaScript complessa.

Lo stesso pattern funziona per qualsiasi scorrimento con una singola azione (rimozione, archiviazione, contrassegno come letto, posticipo). Le immagini dell'azione cambiano, ma non le meccaniche.

Se invece vuoi che uno scorrimento orizzontale apra un insieme di pulsanti di azione o un menu laterale senza eseguire automaticamente un'azione durante lo scorrimento, consulta Scorri per mostrare.

Come implementare

Il componente ha due livelli: un elenco (<ul>) e gli elementi al suo interno. Ogni elemento è strutturato come un <li> esterno, una traccia di scorrimento interna (il contenitore di scorrimento con i punti di aggancio) e un elemento content (la riga visibile). La UI dell'azione (icona del cestino, etichetta di archiviazione e così via) viene visualizzata su entrambi i lati dei contenuti. L'elenco possiede il cablaggio condiviso (configurazione degli elementi lazy, selezione degli elementi appena aggiunti); ogni elemento possiede il proprio rilevamento dello scorrimento.

Passaggio 1: esegui il markup dell'elenco con la traccia e i contenuti

<ul class="SwipeableList">
  <li id="list-item-1" class="SwipeableList-item">
    <div class="SwipeableList-track">
      <div class="SwipeableList-content">Item One</div>
    </div>
  </li>
  <li id="list-item-2" class="SwipeableList-item">
    <div class="SwipeableList-track">
      <div class="SwipeableList-content">Item Two</div>
    </div>
  </li>
  <!-- ...more items... -->
</ul>

Passaggio 2: configura la traccia come container di aggancio orizzontale con tre punti di aggancio

La traccia ha tre colonne a larghezza intera: un distanziatore sinistro (::before), i contenuti e un distanziatore destro (::after). L'aggancio a un distanziatore indica che i contenuti sono completamente fuori schermo, ovvero la posizione di riposo dopo uno scorrimento completato.

La configurazione della traccia è protetta da una classe .is-initialized nell'elemento dell'elenco. Prima che JS esegua l'upgrade della riga, l'elemento viene visualizzato come contenuto semplice senza scorrimento orizzontale. La classe viene aggiunta nel passaggio 4 dopo aver collegato il codice JavaScript che rileva gli scorrimenti. In questo modo, l'utente non può scorrere prima che la funzionalità sia pronta.

.SwipeableList {
  list-style: none;
}

.SwipeableList-item {
  /* Establishes a containing block for the absolutely positioned action
     icons (Step 3) and clips overflow during the row's removal
     animation (Step 4). */
  contain: content;
}

/* The track only becomes a scroll snap container after JS upgrades
   the row by adding `.is-initialized` (see Step 4). */
.SwipeableList-item.is-initialized .SwipeableList-track {
  /* Three full-width columns: left spacer | content | right spacer.
     Width is 100% of the track, so each column fills the viewport row. */
  display: grid;
  grid-template-columns: 100% 100% 100%;

  /* Horizontal scroll only; vertical overflow is clipped so the
     reveal stays inside the row. */
  overflow: scroll clip;

  /* Prevent the swipe from chaining into the page scroll or browser
     back-gesture on iOS/Android. */
  overscroll-behavior-x: none;

  /* Hide the scrollbar; the gesture is the affordance. */
  scrollbar-width: none;

  /* `mandatory` ensures the track always rests on a snap point
     (spacer or content), never partially scrolled. */
  scroll-snap-type: x mandatory;
}

/* Spacers act as the left and right snap targets, AND carry the action's
   reveal color. As the user swipes, the colored spacer slides into view,
   which is what the user sees behind the content. */
.SwipeableList-item.is-initialized .SwipeableList-track::before,
.SwipeableList-item.is-initialized .SwipeableList-track::after {
  content: '';

  /* `scroll-snap-align` is required to make this a valid snap target,
     but the specific value (`start`/`center`/`end`) doesn't matter here
     because each snap point spans the full width of the scroll
     container, so all alignments resolve to the same resting position. */
  scroll-snap-align: start;

  /* `hsl(0 65% 50%)` is an example value; pick whatever fits your
     design (red here signals "delete"; use a different color for
     archive, mark-as-read, etc.). */
  background-color: hsl(0 65% 50%);
}

.SwipeableList-content {
  /* The content sits above the action icons (Step 3), so it covers
     them until the user swipes. */
  position: relative;
  z-index: 2;

  /* Required to make the content a valid snap target (its resting
     position). As with the spacers above, the specific value doesn't
     matter because the snap points are full-width. */
  scroll-snap-align: start;

  /* The content must paint over the revealed spacer color. */
  background: Canvas;

  /* Row separator (example value; customize to taste). */
  border-bottom: 1px solid #eee;
}

/* Gate `scroll-initial-target` behind `.is-initialized` so it only
   applies once the track is actually a scroll container. Setting it on
   the content before then would let the property walk up to the nearest
   scrollable ancestor (typically the document) and shift the page's
   initial scroll position to bring this row's content into view. With
   the gate, the rule is only live when the row's own track can satisfy
   it, so the initial scroll happens inside the track as intended. */
.SwipeableList-item.is-initialized .SwipeableList-content {
  scroll-initial-target: nearest;
}

/* The track is the focusable scroll container, but its overflow is clipped
   so a default focus ring on the track itself would be invisible. Project
   the focus affordance onto the content element (which paints above the
   track) using `:focus-visible` on the track. */
.SwipeableList-track:focus-visible .SwipeableList-content {
  outline: auto;
  outline-offset: -2px;
}

Passaggio 3: aggiungi le icone di azione all'elemento dell'elenco

Il colore dell'azione si trova sugli spaziatori (passaggio 2). L'icona di azione si trova nell'elemento dell'elenco stesso, ancorata ai bordi sinistro e destro della riga.

Il posizionamento, le dimensioni e il movimento di seguito sono un suggerimento di partenza, non un requisito. Regola le dimensioni dell'icona, gli inset dei bordi, la scala di soglia, la durata della transizione e persino la scelta di pseudo-elementi rispetto ai nodi DOM reali per adattarli al tuo design. L'unico requisito meccanico è che l'icona si trovi dietro i contenuti (in modo che i contenuti possano coprirla prima dello scorrimento) e all'interno dell'elemento dell'elenco (in modo che non scorra con lo spaziatore). Tutto il resto è gusto.

/* Action icons painted on the list item. They're absolutely positioned
   inside the row (which is a containing block thanks to `contain: content`
   on `.SwipeableList-item` from Step 2) and sit at z-index 1, so the
   content element (z-index 2) covers them until the user swipes far
   enough. */
.SwipeableList-item.is-initialized::before,
.SwipeableList-item.is-initialized::after {
  /* Inline an SVG as the action icon. Replace this with whatever icon
     fits the action (archive, checkmark, clock, etc.). The `fill='white'`
     is baked into the SVG so it contrasts with the red spacer background;
     adjust if your background color is light. */
  --action-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'><path d='M9 3v1H4v2h1v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1V4h-5V3H9zm0 5h2v9H9V8zm4 0h2v9h-2V8z'/></svg>");

  content: '';
  position: absolute;
  z-index: 1;

  /* The size (`width`) and insets (`left`/`right` below) are example
     values, tune them to match your row height and visual weight.
     The icon fills its box via `background-size: contain`, so changing
     `width` resizes it. */
  width: 1.5em; /* example value, adjust to taste */
  aspect-ratio: 1;
  top: 50%;
  translate: 0 -50%;

  /* Smooth transitions for the icon's visual states: the activate-point
     pop (`scale`, see `.is-activating` below) and the removal fade
     (`scale` + `opacity`, see `.is-removing` below). 0.2s is an example
     duration. */
  transition: scale 0.2s ease, opacity 0.2s ease;

  background: var(--action-icon) center / contain no-repeat;
}
/* Inset from the row edge (example values; adjust to match your layout). */
.SwipeableList-item.is-initialized::before { left: 1.5em; }
.SwipeableList-item.is-initialized::after  { right: 1.5em; }

/* Activating pop: scale the icon up when the user is past the visual
   activate point, so the row's affordance feels reactive. Toggled by JS
   in Step 4. */
.SwipeableList-item.is-activating::before,
.SwipeableList-item.is-activating::after {
  scale: 1.333;
}

/* Removal affordance: fade and shrink the icons as the row collapses.
   Driven by the `is-removing` class added in Step 4 at commit time;
   the existing `transition` on the icons animates the change. */
.SwipeableList-item.is-removing::before,
.SwipeableList-item.is-removing::after {
  scale: 0.5;
  opacity: 0;
}

/* Only show the icon on the *leading* side of the swipe; hide the
   trailing-side one. `data-swipe-direction` is set by JS in Step 4. */
.SwipeableList-item.is-activating[data-swipe-direction="left"]::after,
.SwipeableList-item.is-activating[data-swipe-direction="right"]::before {
  visibility: hidden;
}

Passaggio 4: rileva il gesto di commit con IntersectionObserver

Utilizza IntersectionObserver con root nella traccia, osservando i contenuti. Man mano che l'utente scorre, il rapporto di intersezione dei contenuti con la traccia diminuisce; utilizziamo due soglie:

  • activateThreshold: un rapporto elevato (ad es. 0,8). Quando la parte visibile dei contenuti scende al di sotto di questa soglia, l'utente ha superato il punto di attivazione visiva. Attiva/disattiva l'affordance dell'icona popup.
  • commitThreshold: un rapporto basso (ad es. 0,2). Quando la parte visibile scende al di sotto di questo valore, l'utente ha eseguito il commit. Avvia l'animazione di rimozione immediatamente, senza attendere che il gesto di aggancio si stabilizzi completamente. La riga che si comprime si fonde con lo slancio continuo dello scorrimento dell'utente, il che sembra più reattivo rispetto all'attesa che lo scatto si stabilizzi prima di reagire.

Qui vengono gestiti altri due problemi:

  • Configurazione lazy per elemento: un singolo IntersectionObserver esterno con root nell'area visibile gestisce la configurazione e l'avvio/l'arresto degli observer di scorrimento interni. Gli elementi vengono collegati solo la prima volta che vengono visualizzati e l'osservatore dello scorrimento viene messo in pausa per gli elementi che scorrono fuori dallo schermo. In questo modo, il numero di osservatori attivi rimane limitato e si evita di leggere valori dipendenti dal layout (come clientWidth) prima che l'elemento venga visualizzato.
  • Elementi dinamici: gli elenchi reali crescono nel tempo (rendering iniziale, scorrimento continuo, push del server). Un MutationObserver su <ul> registra tutti gli elementi appena aggiunti con l'observer esterno.
// Per-item handles. Populated when an item is first lazily wired up; read by
// the outer viewport observer to start/stop the inner observer as items enter
// and leave the viewport.
const swipeObservers = new WeakMap();

// Outer observer: drives the entire swipe lifecycle off viewport visibility.
// On first entry, lazily wires the item up (`setupItem` reads layout-dependent
// values like `clientWidth`, which return 0 until the item is rendered). After
// setup, starts the inner observer. On exit, stops the inner observer so
// offscreen items don't track scroll positions.
const viewportObserver = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const item = entry.target;
    if (entry.isIntersecting) {
      const handle = swipeObservers.get(item) ?? setupItem(item);
      handle.observer.observe(handle.content);
    } else {
      const handle = swipeObservers.get(item);
      if (handle) handle.observer.unobserve(handle.content);
    }
  }
});

function setupItem(item) {
  const track = item.querySelector('.SwipeableList-track');
  const content = track.querySelector('.SwipeableList-content');

  // Upgrade the row into "swipeable" mode. This is the gate for all the CSS
  // from Steps 2 and 3 (the track becomes a snap container, the action icons
  // appear). Done *before* the inner observer is attached so the snap
  // container exists by the time intersection callbacks can fire.
  item.classList.add('is-initialized');

  // Tunable thresholds. `activateThreshold` is the visual feedback point
  // (icon pops). `commitThreshold` is the point of no return: once the
  // content is past this point of being off-screen, we commit even if the
  // user releases mid-gesture and the track snaps back. A low value (~0.2)
  // commits before the snap settles, so the remove animation can start
  // during the swipe.
  const activateThreshold = 0.8;
  const commitThreshold = 0.2;

  // One inner observer per item, rooted at the track. Vertical scrolling of
  // the outer list moves root and target together, so the callback only fires
  // for the horizontal swipe.
  const observer = new IntersectionObserver((entries, observer) => {
    const entry = entries.at(-1);
    const ratio = entry.intersectionRatio;

    // Direction the user is swiping toward. A positive offset from the
    // track's left edge means the content has been pulled right (left
    // spacer revealed), so the leading icon is on the left.
    const direction = (entry.boundingClientRect.x - entry.rootBounds.x) > 0
      ? 'left'
      : 'right';

    if (ratio < commitThreshold) {
      // The IO entry's boundingClientRect is the last reliable measurement
      // before the animation starts; reuse it for both the pre-collapse
      // height and the slide-off translate distance.
      removeItem(item, content, direction, entry);
      viewportObserver.unobserve(item);
      observer.disconnect();
      return;
    }

    // Scale up the leading icon while the content is past the activate
    // point; restore it at rest.
    item.classList.toggle('is-activating', ratio < activateThreshold);

    // Hold the previous direction at rest so the icon's exit animation
    // finishes on the side the user was swiping toward.
    if (entry.boundingClientRect.x !== entry.rootBounds.x) {
      item.dataset.swipeDirection = direction;
    }
  }, {
    root: track,
    threshold: [commitThreshold, activateThreshold],
  });

  // Return the handle without starting observation; the outer viewport observer
  // calls `observer.observe(content)` once the item is in view.
  const handle = {observer, content};
  swipeObservers.set(item, handle);
  return handle;
}

async function removeItem(item, content, direction, entry) {
  const opts = { duration: 300, easing: 'ease', fill: 'forwards' };

  const rect = entry.boundingClientRect;
  // Content's pixel offset from the track's left edge.
  const x = rect.x - entry.rootBounds.x;
  // Pixel distance the content needs to travel to be fully out of view.
  const translate = direction === 'left'
    ? rect.width - x
    : -(x + rect.width);

  // Use a combination of CSS transitions (for declarative styles) and
  // WAAPI animations (for computed values) to remove the element,
  // then await the completion of all of them.
  // Note: the content translate animation is important because the
  // height-collapse animation can otherwise finish before the browser's
  // smooth scroll-snap has scrolled the content fully off-screen.
  item.classList.add('is-removing');
  item.animate([{ height: `${rect.height}px` }, { height: '0px' }], opts);
  content.animate([{ translate: `${translate}px` }], opts);
  await Promise.allSettled(
    item.getAnimations({ subtree: true }).map((a) => a.finished),
  );

  // Safari has a scroll-latching bug: removing the node while the swipe
  // gesture's momentum is still resolving causes the next item (which
  // slides up into this one's place) to inherit the scroll and
  // immediately scroll itself off-screen. Detect Safari via
  // `GestureEvent` (a Safari-only API) and defer the actual DOM removal
  // until the gesture has fully settled. The 5s delay is conservative;
  // anything longer than the momentum tail is fine. Mark the row inert
  // so it can't be interacted with during the delay.
  if (globalThis.GestureEvent) {
    item.inert = true;
    setTimeout(() => item.remove(), 5000);
  } else {
    item.remove();
  }
}

function setupList(list) {
  // Observe items already in the list.
  for (const item of list.children) {
    if (item.matches('.SwipeableList-item')) {
      viewportObserver.observe(item);
    }
  }

  // Pick up items added later (initial render after data loads, infinite
  // scroll, server push). Removals don't need MutationObserver handling —
  // the commit branch above already unobserves the item before removing it
  // from the DOM.
  new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      for (const node of mutation.addedNodes) {
        if (node.nodeType === Node.ELEMENT_NODE &&
            node.matches('.SwipeableList-item')) {
          viewportObserver.observe(node);
        }
      }
    }
  }).observe(list, { childList: true });
}

document.querySelectorAll('.SwipeableList').forEach(setupList);

Passaggio 5: utilizza l'azione e l'etichetta adatte al tuo caso d'uso

Per le varianti diverse dalla rimozione, cambiano solo gli elementi visivi e il corpo del gestore del commit:

  • Archivia: sfondo verde/blu, icona di archiviazione, sposta l'elemento in un elenco di archiviazione anziché rimuoverlo.
  • Segna come letto: sfondo attenuato, icona con segno di spunta, aggiornamento dello stato dell'elemento e nuovo rendering (o semplicemente rimozione di una classe .unread).
  • Posticipa: sfondo blu, icona a forma di orologio, nascondi fino a un'ora scelta.

I meccanismi di scorrimento/allineamento/osservazione rimangono invariati.

Azioni diverse per direzione di scorrimento

Una singola riga può anche mostrare due azioni diverse: una per lo scorrimento verso sinistra e una per lo scorrimento verso destra (ad es. "archivia" a destra, "elimina" a sinistra), come fanno molte app di posta native. I meccanismi di scorrimento, aggancio e rilevamento del commit non cambiano affatto: l'unica cosa che cambia è ciò che accade al momento del commit.

Nel passaggio 4, il ramo di commit chiama sempre removeItem(...). Per supportare due azioni, scegli il gestore in base a direction:

if (ratio < commitThreshold) {
  const handler = direction === 'left' ? archiveItem : removeItem;
  handler(item, content, direction, entry);
  viewportObserver.unobserve(item);
  observer.disconnect();
  return;
}

Una nota sulla denominazione: removeItem prende il nome dal caso distruttivo, ma la funzione che esegue (comprimi l'altezza della riga, fai scorrere i contenuti fuori dallo schermo, quindi elimina il nodo) è in realtà una routine generica "questa riga è terminata, rimuovila con un'animazione". Funziona allo stesso modo per l'archiviazione, il contrassegno come letto o la posticipazione: in ogni caso, la riga scompare da questo elenco. Se i gestori non rimuovono effettivamente nulla (ad esempio, entrambi spostano l'elemento altrove), rinominalo con un nome neutro come dismissItem in modo che il codice venga letto correttamente.

Per rendere le due azioni visivamente distinte, assegna un colore e un'icona per ogni direzione all'elemento dell'elenco, poi dipingi la traccia con un gradiente diviso e le due icone degli pseudo-elementi dalle stesse variabili.

.SwipeableList-item {
  /* Action color + icon per swipe direction. `--left-*` is revealed
     when the user swipes RIGHT (e.g., archive); `--right-*` is revealed
     when the user swipes LEFT (e.g., delete). */
  --left-action-color: hsl(140 50% 40%);
  --left-action-icon: url("…archive svg…");
  --right-action-color: hsl(0 65% 50%);
  --right-action-icon: url("…trash svg…");
}

.SwipeableList-item.is-initialized .SwipeableList-track {
  /* Split reveal: left half of the scrollable area gets the left-action
     color, right half gets the right-action color. `background-attachment:
     local` makes the gradient's positioning area the scrollable area
     (3x the visible width), so the default size fills it and the 50% hard
     stop lines up exactly with the midpoint of the resting content column.
     The track's color also stays continuous behind the content as it
     translates off, so the leading-direction color shows the whole way. */
  background-image: linear-gradient(
    to right,
    var(--left-action-color) 50%,
    var(--right-action-color) 50%
  );
  background-attachment: local;
}

/* Per-side icons read from the same variables. */
.SwipeableList-item.is-initialized::before { background-image: var(--left-action-icon); }
.SwipeableList-item.is-initialized::after  { background-image: var(--right-action-icon); }

Con questa configurazione, gli spaziatori non hanno più bisogno di un proprio background-color (il gradiente della barra gestisce la visualizzazione), quindi puoi eliminare la regola background-color su .SwipeableList-track::before, ::after del passaggio 2 se utilizzi questa variante a doppia azione.

Best practice e trappole

  • PUOI usare mandatory snap, non proximity. Con proximity, la riga può rimanere parzialmente scorrevole, lasciando lo sfondo dell'azione parzialmente visibile.
  • DO set overscroll-behavior-x: none on the track. Senza questa proprietà, uno scorrimento eccessivo può attivare il gesto di navigazione a ritroso del browser su iOS/Android.
  • ESEGUI il commit a una soglia prima che lo snap si stabilizzi (ad es. commitThreshold ≈ 0.2) anziché attendere che i contenuti siano completamente fuori schermo. In questo modo, l'animazione di rimozione può iniziare durante il gesto, il che risulta molto più reattivo rispetto all'attesa dell'aggancio.
  • SÌ: gestisci la configurazione per elemento da un'area visibile esterna IntersectionObserver anziché collegare ogni elemento al caricamento della pagina. In questo modo, si evita di leggere i valori dipendenti dal layout (clientWidth e così via) prima che gli elementi vengano visualizzati e si mantiene il numero di osservatori attivi proporzionale a ciò che l'utente può effettivamente vedere.
  • UTILIZZA un MutationObserver nell'elenco quando gli elementi vengono aggiunti in modo dinamico (rendering iniziale dopo il caricamento dei dati, scorrimento continuo, push del server). Senza questo attributo, gli elementi aggiunti dopo il caricamento della pagina non verranno collegati.
  • NON fare affidamento su pointerdown/pointermove/pointerup per eseguire una trasformazione manuale. Perderai l'inerzia, lo snap-back, l'accessibilità da tastiera e la gestione del movimento ridotto che il browser ti offre senza costi.
  • SÌ: conferma le azioni distruttive quando appropriato. Per "rimuovi", valuta la possibilità di mostrare un toast di annullamento al termine dello scorrimento; il gesto è rapido e facile da attivare per errore.
  • SÌ: assicurati che la barra di scorrimento sia attivabile, accessibile da tastiera e che sia presente un affordance di attivazione visiva.
  • SÌ: fornisci alternative accessibili per tutte le azioni pertinenti attivate dallo scorrimento (ad es. un pulsante visibile, un menu contestuale o una modalità di modifica).

Strategie di riserva

Tutte le funzionalità più recenti utilizzate non sono fondamentali per l'esperienza o dispongono di fallback efficaci che possono essere utilizzati in modo affidabile ora.

Fallback per overscroll-behavior

Non è necessario alcun fallback per questo caso d'uso. Sebbene overscroll-behavior abbia un problema di interoperabilità che si manifesta sui container senza overflow scorrevole, la traccia qui è sempre scorrevole in orizzontale (tre colonne a larghezza intera all'interno di un container con larghezza del 100%), quindi la proprietà si comporta in modo coerente tra i browser e il gesto di scorrimento è contenuto in modo affidabile.

Fallback per scrollbar-width

Le barre di scorrimento nascoste sono un miglioramento visivo, non il meccanismo che fa funzionare lo scorrimento per rimuovere. Se il tuo target Baseline non include scrollbar-width, la riga scorre, si allinea, rileva il commit e viene rimossa correttamente; l'esperienza non supportata potrebbe semplicemente mostrare una barra di scorrimento orizzontale. Se il tuo prodotto richiede barre di scorrimento nascoste nei browser meno recenti derivati da WebKit, puoi aggiungere una regola ::-webkit-scrollbar { display: none; } con un ambito limitato per la traccia di scorrimento.

Fallback per scroll-initial-target

Se il target Baseline non include scroll-initial-target, scorri la traccia fino ai contenuti in modo programmatico all'interno di setupItem. Rileva con CSS.supports:

// Hoist the feature detect so the conditional `ResizeObserver` below can be
// skipped entirely when the property is supported.
const needsScrollWorkaround = !CSS.supports('scroll-initial-target', 'nearest');

function setupItem(item) {
  // ...existing setup from Step 4...

  if (needsScrollWorkaround) {
    track.scrollLeft = track.clientWidth;
  }

  // ...attach the inner IntersectionObserver, etc.
}

L'ordine delle chiamate è importante. Lo scorrimento programmatico DEVE essere eseguito:

  1. AFTER .is-initialized viene aggiunto all'elemento dell'elenco (la classe è ciò che trasforma la traccia in un contenitore di scorrimento; l'impostazione di scrollLeft su un elemento non scorrevole non ha alcun effetto).
  2. PRIMA di collegare l'elemento IntersectionObserver interno del passaggio 4. In caso contrario, lo scorrimento programmatico iniziale oltre lo spaziatore sinistro verrà osservato come uno "scorrimento" e attiverà immediatamente il gestore di commit.

La configurazione basata sull'osservatore della viewport esterna (passaggio 4) rende affidabile questo processo: setupItem viene eseguito dopo il rendering dell'elemento, quindi track.clientWidth restituisce un valore reale anziché 0.

Alcuni browser (in particolare Safari) reimpostano anche la posizione di scorrimento del contenitore di aggancio ogni volta che la traccia viene ridimensionata (mostra/nascondi barra degli URL, ridimensionamento dell'area visibile, query del contenitore e così via). Utilizza un ResizeObserver su ogni traccia per applicare nuovamente lo scorrimento. Limita l'accesso con lo stesso controllo CSS.supports: quando scroll-initial-target è supportato, il browser gestisce autonomamente il ripristino dello scorrimento durante il ridimensionamento.

const trackResizeObserver = needsScrollWorkaround
  ? new ResizeObserver((entries) => {
      for (const entry of entries) {
        entry.target.scrollLeft = entry.target.clientWidth;
      }
    })
  : null;

function setupItem(item) {
  // ...existing setup...

  if (needsScrollWorkaround) {
    track.scrollLeft = track.clientWidth;
    trackResizeObserver.observe(track);
  }

  // ...attach the inner IntersectionObserver, etc.
}

Interrompi l'osservazione della traccia prima che venga eseguita l'animazione dell'altezza della riga in removeItem, altrimenti la modifica dell'altezza attiva nuovamente il callback di ridimensionamento durante la rimozione. Aggiungi questo elemento accanto al viewportObserver.unobserve(item) esistente nel branch di commit:

if (ratio < commitThreshold) {
  removeItem(item, content, direction, entry);
  viewportObserver.unobserve(item);
  if (needsScrollWorkaround) trackResizeObserver.unobserve(track);
  observer.disconnect();
  return;
}