Deslize para remover

Os padrões de deslizar para remover são comuns em aplicativos para dispositivos móveis, mas podem ser difíceis de implementar de forma limpa na Web. Ao usar o ajuste de rolagem CSS, é possível criar uma interação de deslizar suave e nativa que se conecta diretamente ao mecanismo de rolagem do navegador. Isso garante alto desempenho e impulso baseado em física sem precisar de uma biblioteca de gestos JavaScript complexa.

O mesmo padrão funciona para qualquer deslize de ação única (remover, arquivar, marcar como lida, adiar). Os recursos visuais da ação mudam, mas a mecânica não.

Se você quiser que um gesto de deslizar horizontal abra um conjunto de botões de ação ou um menu lateral sem realizar uma ação automaticamente, consulte Deslizar para revelar.

Como fazer a implementação

O componente tem duas camadas: uma lista (o <ul>) e os itens dentro dela. Cada item é estruturado como um <li> externo, uma faixa de rolagem interna (o contêiner de rolagem com os pontos de ajuste) e um elemento de conteúdo (a linha visível). A interface revelada da ação (ícone de lixeira, rótulo de arquivo etc.) fica em um dos lados do conteúdo. A lista tem uma fiação compartilhada (configuração de itens lentos, seleção de itens recém-adicionados). Cada item tem a própria detecção de deslizar.

Etapa 1: marcar a lista com faixa e conteúdo

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

Etapa 2: configurar a faixa como um contêiner de ajuste horizontal com três pontos de ajuste

A faixa tem três colunas de largura total: um espaçador à esquerda (::before), o conteúdo e um espaçador à direita (::after). O ajuste a um espaçador significa que o conteúdo está totalmente fora da tela, que é a posição de repouso após um deslize confirmado.

A configuração da faixa é controlada por uma classe .is-initialized no item da lista. Antes de o JS fazer upgrade da linha, o item é renderizado como conteúdo simples, sem barra de rolagem horizontal. A classe é adicionada na Etapa 4, depois que o JavaScript que detecta deslizamentos é conectado. Isso garante que o usuário não possa deslizar antes que a funcionalidade esteja 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;
}

Etapa 3: adicionar os ícones de ação ao item da lista

A cor de ação fica nos espaçadores (etapa 2). O ícone de ação fica no próprio item da lista, ancorado nas bordas esquerda e direita da linha.

O posicionamento, o dimensionamento e o movimento abaixo são uma sugestão inicial, não um requisito. Ajuste o tamanho do ícone, as margens, a escala de pop-up, a duração da transição e até mesmo a escolha de pseudoelementos em vez de nós DOM reais para combinar com seu design. O único requisito mecânico é que o ícone fique atrás do conteúdo (para que o conteúdo possa cobri-lo antes de deslizar) e dentro do item da lista (para que ele não role com o espaçador). Todo o resto é gosto.

/* 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;
}

Etapa 4: detectar o gesto de confirmação com IntersectionObserver

Use IntersectionObserver com raiz na faixa, observando o conteúdo. Conforme o usuário desliza, a proporção de interseção do conteúdo com a faixa diminui. Usamos dois limites:

  • activateThreshold: uma proporção alta (por exemplo, 0,8). Quando a parte visível do conteúdo fica abaixo disso, o usuário ultrapassou o ponto de ativação visual. Ative ou desative o recurso de pop-up de ícone.
  • commitThreshold: uma proporção baixa (por exemplo, 0,2). Quando a parte visível fica abaixo disso, o usuário se comprometeu. Inicie a animação de remoção imediatamente, sem esperar que o gesto de ajuste seja totalmente concluído. A linha que se fecha se mistura ao movimento de deslizar do usuário, o que parece mais responsivo do que esperar que o ajuste seja concluído antes de reagir.

Mais duas preocupações são abordadas aqui:

  • Configuração lenta por item: um único IntersectionObserver externo com raiz na janela de visualização controla a configuração e o início/interrupção dos observadores de deslizar internos. Os itens só são conectados na primeira vez que aparecem na tela, e os itens que saem da tela têm o observador de deslizar pausado. Isso mantém a contagem de observadores ativos limitada e evita a leitura de valores dependentes do layout (como clientWidth) antes que o item seja renderizado.
  • Itens dinâmicos: as listas reais crescem com o tempo (renderização inicial, rolagem infinita, push de servidor). Um MutationObserver no <ul> registra todos os itens recém-adicionados com o 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);

Etapa 5: usar a ação e o rótulo que se encaixam no seu caso de uso

Para variantes que não sejam de remoção, apenas os recursos visuais e o corpo do gerenciador de confirmação mudam:

  • Arquivar: plano de fundo verde/azul, ícone de arquivo, move o item para uma lista de arquivos em vez de removê-lo.
  • Marcar como lida: plano de fundo mais claro, ícone de verificação, atualização do estado do item e nova renderização (ou apenas remoção de uma classe .unread).
  • Adiar: plano de fundo azul, ícone de relógio, ocultar até um horário escolhido.

A mecânica de rolagem/ajuste/observação não mudou.

Ações diferentes por direção de deslize

Uma única linha também pode expor duas ações diferentes: uma para deslizar para a esquerda e outra para deslizar para a direita (por exemplo, "arquivar" à direita, "excluir" à esquerda), como muitos apps de e-mail nativos fazem. As mecânicas de rolagem, ajuste e detecção de confirmação não mudam. A única coisa que muda é o que acontece no momento da confirmação.

Na etapa 4, a ramificação de commit sempre chama removeItem(...). Para oferecer suporte a duas ações, escolha o gerenciador com base em direction:

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

Uma observação sobre a nomenclatura: removeItem é nomeado para o caso destrutivo, mas a função que ele executa (recolher a altura da linha, deslizar o conteúdo para fora da tela e, em seguida, soltar o nó) é, na verdade, uma rotina genérica "esta linha está pronta, anime-a para fora". Isso também funciona para arquivar, marcar como lida ou adiar. De qualquer forma, a linha desaparece desta lista. Se os gerenciadores não removerem nada (por exemplo, se ambos moverem o item para outro lugar), renomeie-o para algo neutro, como dismissItem, para que o código seja lido corretamente.

Para diferenciar visualmente as duas ações, eleve uma cor e um ícone para cada direção no item da lista. Em seguida, pinte a faixa com um gradiente dividido e os dois ícones de pseudoelemento das mesmas variáveis.

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

Com essa configuração, os espaçadores não precisam mais de uma cor de fundo própria (o gradiente da faixa lida com a revelação). Portanto, você pode descartar a regra background-color em .SwipeableList-track::before, ::after da Etapa 2 se estiver usando essa variante de ação dupla.

Práticas recomendadas e armadilhas

  • USE o snap mandatory, não proximity. Com proximity, a linha pode ficar parcialmente rolada, deixando o plano de fundo da ação meio visível.
  • DEFINA overscroll-behavior-x: none na faixa. Sem ele, um deslize excessivo pode acionar o gesto de navegação de retorno do navegador no iOS/Android.
  • FAÇA o commit em um limite antes que o ajuste seja concluído (por exemplo, commitThreshold ≈ 0.2), em vez de esperar que o conteúdo saia totalmente da tela. Isso permite que a animação de remoção comece durante o gesto, o que é muito mais responsivo do que esperar que o ajuste seja concluído.
  • FAÇA a configuração por item de uma janela de visualização externa IntersectionObserver em vez de conectar todos os itens no carregamento de página. Isso evita a leitura de valores dependentes do layout (clientWidth etc.) antes que os itens sejam renderizados e mantém a contagem de observadores ativos proporcional ao que o usuário realmente pode ver.
  • USE um MutationObserver na lista quando os itens forem adicionados dinamicamente (renderização inicial após o carregamento de dados, rolagem infinita, push de servidor). Sem ele, os itens adicionados ao final após o carregamento da página não serão conectados.
  • NÃO use pointerdown/pointermove/pointerup para realizar uma transformação manual. Você vai perder o impulso, o retorno, a acessibilidade do teclado e o tratamento de movimento reduzido que o navegador oferece sem custo financeiro.
  • CONFIRME ações destrutivas quando apropriado. Para "remover", considere mostrar um aviso de desfazer após o deslizar. O gesto é rápido e fácil de acionar por acidente.
  • É RECOMENDÁVEL garantir que a barra de rolagem seja focalizável, acessível pelo teclado e que haja uma indicação visual de foco.
  • FAÇA: ofereça alternativas acessíveis para todas as ações relevantes acionadas pelo gesto de deslizar (por exemplo, um botão visível, um menu de contexto ou um modo de edição).

Estratégias de fallback

Todos os recursos mais recentes usados não são essenciais para a experiência ou têm substitutos robustos que podem ser usados de forma confiável agora.

Substituto para overscroll-behavior

Nenhum substituto é necessário para esse caso de uso. Embora o overscroll-behavior tenha um problema de interoperabilidade que se manifesta em contêineres sem overflow rolável, a faixa aqui é sempre rolável horizontalmente (três colunas de largura total dentro de um contêiner de 100% de largura), então a propriedade se comporta de maneira consistente em todos os navegadores, e o gesto de deslizar é contido de forma confiável.

Substituto para scrollbar-width

As barras de rolagem ocultas são uma melhoria visual, não o mecanismo que faz o recurso de deslizar para remover funcionar. Se o destino de referência não incluir scrollbar-width, a linha ainda vai rolar, ajustar, detectar o commit e remover corretamente. A experiência sem suporte pode simplesmente mostrar uma barra de rolagem horizontal. Se o produto exigir barras de rolagem ocultas em navegadores mais antigos derivados do WebKit, adicione uma regra ::-webkit-scrollbar { display: none; } de escopo restrito para a faixa de deslize.

Substituto para scroll-initial-target

Se o destino de referência não incluir scroll-initial-target, role a faixa até o conteúdo de forma programática dentro de setupItem. Detectar com 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.
}

A ordem das chamadas é importante. A rolagem programática PRECISA ser executada:

  1. AFTER: .is-initialized é adicionado ao item da lista. A classe é o que transforma a faixa em um contêiner de rolagem. Definir scrollLeft em um elemento não rolável não tem efeito.
  2. ANTES de anexar o IntersectionObserver interno da Etapa 4. Caso contrário, a rolagem programática inicial além do espaçador esquerdo será observada como um "deslizar" e acionará imediatamente o gerenciador de confirmação.

A configuração do observador da janela de visualização externa (etapa 4) é o que torna isso confiável: setupItem é executado depois que o item é renderizado, então track.clientWidth retorna um valor real em vez de 0.

Alguns navegadores (principalmente o Safari) também redefinem a posição de rolagem do contêiner de ajuste sempre que a faixa é redimensionada (mostrar/ocultar a barra de URL, redimensionamento da janela de visualização, consultas de contêiner etc.). Use um ResizeObserver em cada faixa para reaplicar a rolagem. Proteja-o com a mesma verificação CSS.supports. Quando scroll-initial-target é compatível, o navegador processa a restauração de rolagem no momento do redimensionamento.

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

Remova a observação da faixa antes que a animação de altura da linha seja executada em removeItem. Caso contrário, a mudança de altura vai acionar novamente o callback de redimensionamento durante a remoção. Adicione isso ao lado do viewportObserver.unobserve(item) atual na ramificação de commit:

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