スワイプして削除

スワイプして削除するパターンはモバイルアプリでは一般的ですが、ウェブで適切に実装するのは難しい場合があります。CSS スクロール スナップを使用すると、ブラウザのスクロール エンジンに直接フックする、スムーズでネイティブなスワイプ操作を作成できます。これにより、複雑な JavaScript ジェスチャー ライブラリを必要とせずに、高いパフォーマンスと物理ベースのモーメンタムを実現できます。

同じパターンは、1 つのアクションのスワイプ(削除、アーカイブ、既読にする、スヌーズ)でも機能します。アクションのビジュアルは変わりますが、メカニズムは変わりません。

代わりに、水平スワイプで一連の操作ボタンまたはサイドメニューをスナップして開き、スワイプ中に操作を自動的にコミットしないようにする場合は、スワイプして表示をご覧ください。

導入方法

このコンポーネントには、リスト(<ul>)とその中のアイテムという 2 つのレイヤがあります。各アイテムは、外側の <li>、内側のスクロール トラック(スナップポイントを含むスクロール コンテナ)、コンテンツ要素(表示される行)として構造化されています。アクションの表示された UI(ゴミ箱アイコン、アーカイブ ラベルなど)は、コンテンツのどちらかの側に表示されます。リストは共有配線(遅延アイテムの設定、新しく追加されたアイテムの取得)を所有し、各アイテムは独自のスワイプ検出を所有します。

ステップ 1: トラックとコンテンツを含むリストをマークアップする

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

ステップ 2: トラックを 3 つのスナップ ポイントを持つ水平スナップ コンテナとして構成する

トラックには、左側のスペーサー(::before)、コンテンツ、右側のスペーサー(::after)の 3 つの全幅列があります。スペーサーにスナップすると、コンテンツが完全に画面外に移動します。これは、スワイプが完了した後の静止位置です。

トラック構成は、リストアイテムの .is-initialized クラスの背後にゲートされます。JS が行をアップグレードする前は、アイテムは水平スクローラーのないプレーンなコンテンツとしてレンダリングされます。このクラスは、スワイプを検出する JavaScript が接続された後のステップ 4 で追加されます。これにより、機能の準備が整う前にユーザーがスワイプできないようにします。

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

ステップ 3: リストアイテムにアクション アイコンを追加する

アクションの色はスペーサーにあります(ステップ 2)。アクション アイコンは、リストアイテム自体に配置され、行の左右の端に固定されます。

以下の配置、サイズ、モーションは推奨される開始点であり、要件ではありません。アイコンのサイズ、エッジのインセット、しきい値ポップのスケール、遷移時間、さらには疑似要素と実際の DOM ノードの選択を、デザインに合わせて調整できます。機械的な要件は、アイコンがコンテンツの背後にあり(スワイプ前にコンテンツがアイコンを覆うことができるようにするため)、リストアイテム内にある(スペーサーと一緒にスクロールしないようにするため)ことだけです。それ以外は好みの問題です。

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

ステップ 4: IntersectionObserver でコミット ジェスチャーを検出する

トラックをルートとする IntersectionObserver を使用して、コンテンツを監視します。ユーザーがスワイプすると、コンテンツとトラックの交差率が低下します。2 つのしきい値を使用します。

  • activateThreshold: 高い比率(例: 0.8)。コンテンツの表示部分がこの値を下回ると、ユーザーは視覚的なアクティブ ポイントを過ぎたことになります。アイコンポップ アフォーダンスを切り替えます。
  • commitThreshold: 低い比率(例: 0.2)。表示部分がこれより小さくなると、ユーザーはコミットしたことになります。スナップ ジェスチャーが完全に終了するのを待たずに、直ちに削除アニメーションを開始します。折りたたまれる行は、ユーザーの継続的なスワイプの勢いに溶け込みます。これは、反応する前にスナップが着地するのを待つよりも、よりレスポンシブに感じられます。

ここでは、さらに 2 つの懸念事項が処理されます。

  • アイテムごとの遅延セットアップ: ビューポートをルートとする単一の外側の IntersectionObserver が、内側のスワイプ オブザーバーのセットアップと開始 / 停止を駆動します。アイテムは、最初にスクロールして表示されたときにのみ接続され、画面外にスクロールされたアイテムは、スワイプ オブザーバーが一時停止されます。これにより、アクティブなオブザーバーの数が制限され、アイテムがレンダリングされる前にレイアウト依存の値(clientWidth など)が読み取られるのを防ぐことができます。
  • 動的アイテム: 実際のリストは時間の経過とともに増加します(初期レンダリング、無限スクロール、サーバー プッシュ)。<ul> の MutationObserver は、新しく追加されたアイテムを外部オブザーバーに登録します。
// 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);

ステップ 5: ユースケースに合ったアクションとラベルを使用する

削除以外のバリアントでは、commit ハンドラのビジュアルと本文のみが変更されます。

  • アーカイブ: 緑色 / 青色の背景、アーカイブ アイコン。アイテムを削除するのではなく、アーカイブ リストに移動します。
  • 既読にする: 背景を控えめにする、チェックマーク アイコン、更新アイテムの状態、再レンダリング(または .unread クラスを削除)。
  • スヌーズ: 青色の背景、時計アイコン、指定した時間まで非表示にします。

スクロール、スナップ、監視のメカニズムは変更されていません。

スワイプの方向ごとに異なるアクション

また、1 つの行で2 つの異なるアクションを公開することもできます。1 つは左スワイプ用、もう 1 つは右スワイプ用です(例: 右に「アーカイブ」、左に「削除」)。これは、多くのネイティブ メールアプリで行われている方法です。スクロール、スナップ、commit 検出のメカニズムはまったく変わりません。変わるのは、commit 時に何が起こるかだけです。

ステップ 4 では、commit ブランチは常に removeItem(...) を呼び出します。2 つのアクションをサポートするには、direction に基づいてハンドラを選択します。

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

命名に関する注意: removeItem は破壊的なケースにちなんで命名されていますが、実行する関数(行の高さを折りたたみ、コンテンツを画面外にスライドさせてから、ノードをドロップする)は、実際には一般的な「この行は完了したため、アニメーションで消す」ルーティンです。アーカイブ、既読にする、スヌーズでも同様に機能します。いずれの場合も、行はこのリストから消えます。ハンドラが実際には何も削除しない場合(たとえば、どちらもアイテムを別の場所に移動する場合)は、コードが正しく読み取られるように、dismissItem のような中立的な名前に変更します。

2 つのアクションを視覚的に区別するには、各方向の色とアイコンをリストアイテムにホイスティングし、同じ変数から分割グラデーションと 2 つの疑似要素アイコンでトラックをペイントします。

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

この設定では、スペーサーに独自の background-color を設定する必要がなくなります(トラックのグラデーションが表示を処理します)。そのため、このデュアルアクション バリアントを使用する場合は、ステップ 2 の .SwipeableList-track::before, ::after の background-color ルールを削除できます。

ベスト プラクティスと注意点

  • 推奨: proximity ではなく mandatory スナップを使用します。proximity を使用すると、行を部分的にスクロールしたままにできるため、アクションの背景が半分表示されたままになります。
  • 推奨: トラックに overscroll-behavior-x: none を設定します。これがないと、オーバースワイプによって iOS / Android でブラウザの「戻る」ナビゲーション ジェスチャーがトリガーされる可能性があります。
  • 推奨事項: コンテンツが完全に画面外に移動するのを待つのではなく、スナップが落ち着く前のしきい値(commitThreshold ≈ 0.2 など)で commit します。これにより、ジェスチャー中に削除アニメーションが開始されるため、スナップが完了するのを待つよりもはるかに応答性が高くなります。
  • 推奨: ページ読み込み時にすべてのアイテムを接続するのではなく、外側のビューポート IntersectionObserver からアイテムごとの設定を駆動します。これにより、アイテムがレンダリングされる前にレイアウト依存の値(clientWidth など)が読み取られるのを回避し、アクティブなオブザーバーの数をユーザーが実際に確認できる数に比例させることができます。
  • アイテムが動的に追加される場合(データ読み込み後の初期レンダリング、無限スクロール、サーバー プッシュ)、リストで MutationObserver を使用する。これがないと、ページの読み込み後に追加されたアイテムは接続されません。
  • 手動変換を推進するために pointerdown/pointermove/pointerup に依存しないでください。ブラウザが無料で提供する、慣性、スナップバック、キーボード アクセシビリティ、モーション軽減処理が失われます。
  • 推奨: 破壊的な操作は、必要に応じて確認します。「削除」の場合、スワイプが完了した後に元に戻すトーストを表示することを検討してください。このジェスチャーはすばやく簡単に実行できるため、誤ってトリガーされる可能性があります。
  • 推奨: スクロール トラックがフォーカス可能で、キーボードでアクセスでき、視覚的なフォーカス アフォーダンスがあることを確認します。
  • 推奨: スワイプによってトリガーされる関連アクション(表示ボタン、コンテキスト メニュー、編集モードなど)に、アクセス可能な代替手段を提供します。

代替戦略

使用されている新しい機能はすべて、エクスペリエンスの中核ではないか、現在確実に使用できる堅牢なフォールバックを備えています。

overscroll-behavior の代替

このユースケースでは、フォールバックは必要ありません。overscroll-behavior には、スクロール可能なオーバーフローのないコンテナで発生する相互運用性の問題がありますが、ここでのトラックは常に水平方向にスクロール可能です(100% 幅のコンテナ内に 3 つの全幅列)。そのため、プロパティはブラウザ間で一貫して動作し、スワイプ操作は確実に含まれます。

scrollbar-width の代替

スクロールバーを非表示にするのは視覚的な改善であり、スワイプして削除する動作を実現するメカニズムではありません。ベースライン ターゲットに scrollbar-width が含まれていない場合でも、行はスクロール、スナップ、commit の検出、削除を正しく行います。サポートされていないエクスペリエンスでは、水平スクロールバーが表示されるだけです。古い WebKit 派生ブラウザでスクロールバーを非表示にする必要がある場合は、スワイプ トラックにスコープを絞った ::-webkit-scrollbar { display: none; } ルールを追加できます。

scroll-initial-target の代替

ベースライン ターゲットに scroll-initial-target が含まれていない場合は、setupItem 内でプログラムによってトラックをコンテンツまでスクロールします。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.
}

呼び出しの順序が重要です。プログラムによるスクロールは、次の条件を満たす必要があります。

  1. AFTER .is-initialized がリストアイテムに追加されます(このクラスは、トラックをスクロール コンテナに変換するものです。スクロール不可の要素に scrollLeft を設定しても何も起こりません)。
  2. ステップ 4 の内側の IntersectionObserver を取り付ける前に、そうしないと、左側のスペーサーを通過する最初のプログラムによるスクロールが「スワイプ」として認識され、commit ハンドラがすぐに呼び出されます。

外側のビューポート オブザーバーから設定を駆動する(ステップ 4)ことで、信頼性が高まります。setupItem はアイテムがレンダリングされた後に実行されるため、track.clientWidth は 0 ではなく実際の値を返します。

一部のブラウザ(特に Safari)では、トラックのサイズが変更されるたびに(URL バーの表示 / 非表示、ビューポートのサイズ変更、コンテナクエリなど)、スナップ コンテナのスクロール位置もリセットされます。各トラックで ResizeObserver を使用して、スクロールを再適用します。同じ CSS.supports チェックの背後にゲートします。scroll-initial-target がサポートされている場合、ブラウザはサイズ変更時のスクロール復元を自動的に処理します。

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

removeItem で行の高さのアニメーションが実行される前に、トラックの監視を解除します。そうしないと、高さの変更によって、削除中にサイズ変更コールバックが再度トリガーされます。commit ブランチの既存の viewportObserver.unobserve(item) とともに、次のコードを追加します。

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