Desliza para sacar

Los patrones de deslizar para quitar son comunes en las aplicaciones para dispositivos móviles, pero puede ser difícil implementarlos de forma limpia en la Web. Con el ajuste de desplazamiento de CSS, puedes crear una interacción de deslizamiento fluida y de aspecto nativo que se conecte directamente con el motor de desplazamiento del navegador. Esto garantiza un alto rendimiento y un impulso basado en la física sin necesidad de una biblioteca de gestos de JavaScript compleja.

El mismo patrón funciona para cualquier deslizamiento de acción única (quitar, archivar, marcar como leído, posponer). Los elementos visuales de la acción cambian, pero la mecánica no.

Si, en cambio, quieres que un deslizamiento horizontal abra un conjunto de botones de acción o un menú lateral sin confirmar automáticamente una acción durante el deslizamiento, consulta Deslizar para revelar.

Cómo implementarlo

El componente tiene dos capas: una lista (el <ul>) y los elementos que contiene. Cada elemento se estructura como un <li> exterior, una pista de desplazamiento interior (el contenedor de desplazamiento con los puntos de ajuste) y un elemento de contenido (la fila visible). La IU revelada de la acción (ícono de papelera, etiqueta de archivo, etc.) se encuentra a ambos lados del contenido. La lista tiene un cableado compartido (configuración de elementos diferidos, selección de elementos recién agregados); cada elemento tiene su propia detección de deslizamiento.

Paso 1: Marca la lista con la pista y el contenido

<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>

Paso 2: Configura la pista como un contenedor de ajuste horizontal con tres puntos de ajuste

La pista tiene tres columnas de ancho completo: un espaciador izquierdo (::before), el contenido y un espaciador derecho (::after). Ajustar a un espaciador significa que el contenido está completamente fuera de la pantalla, que es la posición de reposo después de un deslizamiento confirmado.

La configuración de segmento está restringida detrás de una clase .is-initialized en el elemento de la lista. Antes de que JS actualice la fila, el elemento solo se renderiza como contenido sin formato sin un desplazador horizontal. La clase se agrega en el paso 4 una vez que se conecta el JavaScript que detecta los deslizamientos. Esto garantiza que el usuario no pueda deslizar el dedo antes de que la funcionalidad esté lista.

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

Paso 3: Agrega los íconos de acción al elemento de lista

El color de acción se encuentra en los espaciadores (paso 2). El ícono de acción se encuentra en el elemento de la lista, anclado a los bordes izquierdo y derecho de la fila.

La ubicación, el tamaño y el movimiento que se muestran a continuación son una sugerencia inicial, no un requisito. Ajusta el tamaño del ícono, los márgenes de los bordes, la escala de aparición del umbral, la duración de la transición y hasta la elección de pseudoelementos en comparación con los nodos del DOM reales para que coincidan con tu diseño. El único requisito mecánico es que el ícono se ubique detrás del contenido (para que el contenido pueda cubrirlo antes de deslizarlo) y dentro del elemento de la lista (para que no se desplace con el espaciador). Todo lo demás es cuestión de 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;
}

Paso 4: Detecta el gesto de confirmación con IntersectionObserver

Usa IntersectionObserver con raíz en la pista y observa el contenido. A medida que el usuario desliza el dedo, la proporción de intersección del contenido con la pista disminuye. Usamos dos umbrales:

  • activateThreshold: un índice alto (p. ej., 0.8) Cuando la parte visible del contenido cae por debajo de este valor, el usuario supera el punto de activación visual. Activa o desactiva la affordance de ícono emergente.
  • commitThreshold: un índice bajo (p. ej., 0.2) Cuando la parte visible cae por debajo de este valor, el usuario se compromete. Inicia la animación de eliminación de inmediato, sin esperar a que se complete el gesto de ajuste. La fila que se contrae se integra en el impulso de deslizamiento continuo del usuario, lo que se siente más responsivo que esperar a que se complete el ajuste antes de reaccionar.

Aquí se abordan dos inquietudes más:

  • Configuración diferida por elemento: Un solo IntersectionObserver externo con raíz en el viewport controla la configuración y el inicio o la detención de los observadores de deslizamiento internos. Los elementos solo se conectan la primera vez que se desplazan para aparecer en la vista, y los elementos que se desplazan fuera de la pantalla tienen su observador de deslizamiento pausado. Esto mantiene limitado el recuento de observadores activos y evita leer valores que dependen del diseño (como clientWidth) antes de que se renderice el elemento.
  • Elementos dinámicos: Las listas reales crecen con el tiempo (renderización inicial, desplazamiento infinito, push desde servidor). Un MutationObserver en el <ul> registra cualquier elemento agregado recientemente con el observador externo.
// 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);

Paso 5: Usa la acción y la etiqueta que se adapten a tu caso de uso

En el caso de las variantes que no sean de eliminación, solo cambian los elementos visuales y el cuerpo del controlador de confirmación:

  • Archivar: Fondo verde o azul, ícono de archivo, mueve el elemento a una lista de archivo en lugar de quitarlo.
  • Marcar como leído: fondo atenuado, ícono de marca de verificación, actualización del estado del elemento y nueva renderización (o simplemente quitar una clase .unread).
  • Posponer: fondo azul, ícono de reloj, ocultar hasta una hora elegida.

La mecánica de desplazamiento, ajuste y observación no cambia.

Diferentes acciones según la dirección de deslizamiento

Una sola fila también puede exponer dos acciones diferentes: una para deslizar el dedo hacia la izquierda y otra para deslizarlo hacia la derecha (p.ej., "archivar" a la derecha, "borrar" a la izquierda), como lo hacen muchas apps de correo electrónico nativas. Las mecánicas de desplazamiento, ajuste y detección de confirmación no cambian en absoluto; lo único que cambia es lo que sucede en el momento de la confirmación.

En el paso 4, la rama de confirmación siempre llama a removeItem(...). Para admitir dos acciones, elige el controlador según direction:

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

Una nota sobre los nombres: removeItem se llama así por el caso destructivo, pero la función que ejecuta (contraer la altura de la fila, deslizar el contenido fuera de la pantalla y, luego, soltar el nodo) es, en realidad, una rutina genérica de "esta fila está lista, quítala con una animación". Funciona igual de bien para archivar, marcar como leído o posponer: la fila desaparece de esta lista de cualquier manera. Si tus controladores no quitan nada (p.ej., ambos mueven el elemento a otro lugar), cámbiale el nombre a algo neutral, como dismissItem, para que el código se lea correctamente.

Para que las dos acciones se distingan visualmente, eleva un color y un ícono para cada dirección en el elemento de la lista y, luego, pinta la pista con un gradiente dividido y los dos íconos de pseudoelemento de las mismas variables.

.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 esta configuración, los espaciadores ya no necesitan su propio color de fondo (el gradiente de la pista controla la revelación), por lo que puedes quitar la regla background-color en .SwipeableList-track::before, ::after del paso 2 si usas esta variante de doble acción.

Prácticas recomendadas y errores comunes

  • SÍ uses el snap mandatory, no proximity. Con proximity, la fila puede quedar parcialmente desplazada, lo que deja el fondo de la acción medio visible.
  • SÍ establece overscroll-behavior-x: none en la pista. Sin ella, un deslizamiento excesivo puede activar el gesto de navegación hacia atrás del navegador en iOS o Android.
  • SÍ confirma en un umbral antes de que se establezca el ajuste (p.ej., commitThreshold ≈ 0.2) en lugar de esperar a que el contenido esté completamente fuera de la pantalla. Esto permite que la animación de eliminación comience durante el gesto, lo que se siente mucho más responsivo que esperar a que se complete el ajuste.
  • SÍ controla la configuración de cada elemento desde un viewport externo IntersectionObserver en lugar de conectar cada elemento cuando se carga la página. Esto evita que se lean los valores que dependen del diseño (clientWidth, etc.) antes de que se rendericen los elementos y mantiene el recuento de observadores activos proporcional a lo que el usuario puede ver.
  • SÍ usa un MutationObserver en la lista cuando se agreguen elementos de forma dinámica (renderización inicial después de que se carguen los datos, desplazamiento infinito, push desde servidor). Sin ella, los elementos que se agreguen después de la carga de la página no se conectarán.
  • NO confíes en pointerdown/pointermove/pointerup para realizar una transformación manual. Perderás el impulso, el retroceso, la accesibilidad del teclado y el manejo de movimiento reducido que el navegador te brinda de forma gratuita.
  • SÍ confirma las acciones destructivas cuando sea apropiado. En el caso de "quitar", considera mostrar un mensaje de aviso para deshacer la acción después de que se complete el deslizamiento, ya que el gesto es rápido y fácil de activar por accidente.
  • SÍ asegúrate de que la barra de desplazamiento sea enfocable, accesible con el teclado y que haya una affordance de enfoque visual.
  • SÍ proporciona alternativas accesibles para cualquier acción relevante que se active con el deslizamiento (p.ej., un botón visible, un menú contextual o un modo de edición).

Estrategias de resguardo

Todas las funciones más recientes que se usan no son fundamentales para la experiencia o tienen resguardos sólidos que se pueden usar de forma confiable en este momento.

Resguardo para overscroll-behavior

No se necesita un resguardo para este caso de uso. Aunque overscroll-behavior tiene un problema de interoperabilidad que se manifiesta en contenedores sin desbordamiento desplazable, la pista aquí siempre se puede desplazar horizontalmente (tres columnas de ancho completo dentro de un contenedor de ancho del 100%), por lo que la propiedad se comporta de manera coherente en todos los navegadores y el gesto de deslizamiento se contiene de manera confiable.

Resguardo para scrollbar-width

Las barras de desplazamiento ocultas son una mejora visual, no el mecanismo que hace que funcione el deslizamiento para quitar. Si tu objetivo de Baseline no incluye scrollbar-width, la fila aún se desplaza, se ajusta, detecta la confirmación y se quita correctamente. Es posible que la experiencia no compatible simplemente muestre una barra de desplazamiento horizontal. Si tu producto requiere barras de desplazamiento ocultas en navegadores más antiguos derivados de WebKit, puedes agregar una regla ::-webkit-scrollbar { display: none; } de alcance limitado para la pista de deslizamiento.

Resguardo para scroll-initial-target

Si tu destino de Baseline no incluye scroll-initial-target, desplaza la pista al contenido de manera programática dentro de setupItem. Detecta 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.
}

El orden de las llamadas es importante. El desplazamiento programático DEBE ejecutarse de la siguiente manera:

  1. AFTER Se agrega .is-initialized al elemento de la lista (la clase es lo que convierte la pista en un contenedor de desplazamiento; establecer scrollLeft en un elemento que no se puede desplazar no tiene ningún efecto).
  2. ANTES de que se adjunte el IntersectionObserver interno del paso 4. De lo contrario, el desplazamiento programático inicial más allá del espaciador izquierdo se observará como un "deslizamiento" y activará de inmediato el controlador de confirmación.

La configuración de la conducción desde el observador de viewport externo (paso 4) es lo que hace que esto sea confiable: setupItem se ejecuta después de que se renderiza el elemento, por lo que track.clientWidth devuelve un valor real en lugar de 0.

Algunos navegadores (en particular, Safari) también restablecen la posición de desplazamiento del contenedor de ajuste cada vez que cambia el tamaño de la pista (mostrar/ocultar la barra de URL, cambiar el tamaño del viewport, consultas de contenedor, etcétera). Usa un ResizeObserver en cada pista para volver a aplicar el desplazamiento. Protégela con la misma verificación de CSS.supports: cuando se admite scroll-initial-target, el navegador controla por sí mismo el restablecimiento del desplazamiento en el momento del cambio de tamaño.

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

Deja de observar la pista antes de que se ejecute la animación de altura de la fila en removeItem. De lo contrario, el cambio de altura volverá a activar la devolución de llamada de cambio de tamaño durante la eliminación. Agrega esto junto con el viewportObserver.unobserve(item) existente en la rama de confirmación:

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