Geser untuk hapus

Pola geser untuk menghapus umum digunakan di aplikasi seluler, tetapi dapat sulit diterapkan dengan rapi di web. Dengan menggunakan CSS Scroll Snap, Anda dapat membuat interaksi geser yang lancar dan terasa native yang terhubung langsung ke mesin scrolling browser. Hal ini memastikan performa tinggi dan momentum berbasis fisika tanpa memerlukan library gestur JavaScript yang kompleks.

Pola yang sama berlaku untuk setiap geser satu tindakan (hapus, arsipkan, tandai sebagai telah dibaca, tunda). Visual tindakan berubah; mekanismenya tidak.

Jika Anda ingin geser horizontal untuk membuka sekumpulan tombol tindakan atau menu samping tanpa otomatis melakukan tindakan selama geser, lihat Geser untuk menampilkan.

Cara menerapkan

Komponen ini memiliki dua lapisan: daftar (<ul>) dan item di dalamnya. Setiap item disusun sebagai <li> luar, jalur scroll dalam (penampung scroll dengan titik snap), dan elemen konten (baris yang terlihat). UI yang ditampilkan tindakan (ikon sampah, label arsip, dll.) berada di salah satu sisi konten. Daftar memiliki wiring bersama (penyiapan item lambat, mengambil item yang baru ditambahkan); setiap item memiliki deteksi geserannya sendiri.

Langkah 1: Beri markup pada daftar dengan lagu dan konten

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

Langkah 2: Konfigurasikan trek sebagai penampung snap horizontal dengan tiga titik snap

Trek memiliki tiga kolom lebar penuh: pengatur jarak kiri (::before), konten, dan pengatur jarak kanan (::after). Menempel ke pengatur jarak berarti konten sepenuhnya berada di luar layar, yang merupakan posisi diam setelah geser yang dilakukan.

Konfigurasi jalur dibatasi di balik class .is-initialized pada item daftar. Sebelum JS mengupgrade baris, item hanya dirender sebagai konten biasa tanpa scroller horizontal. Class ditambahkan di Langkah 4 setelah JavaScript yang mendeteksi geser dihubungkan. Hal ini memastikan pengguna tidak dapat menggeser sebelum fungsi siap.

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

Langkah 3: Tambahkan ikon tindakan ke item daftar

Warna tindakan ada di pengatur jarak (Langkah 2). Ikon tindakan berada di item daftar itu sendiri, yang ditambatkan ke tepi kiri dan kanan baris.

Penempatan, ukuran, dan gerakan di bawah ini adalah saran awal, bukan persyaratan. Sesuaikan ukuran ikon, inset tepi, skala pop-up nilai minimum, durasi transisi, dan bahkan pilihan elemen pseudo vs. node DOM nyata agar sesuai dengan desain Anda. Satu-satunya persyaratan mekanis adalah ikon berada di belakang konten (sehingga konten dapat menutupinya sebelum digeser) dan di dalam item daftar (sehingga tidak di-scroll dengan pengatur jarak). Sisanya adalah selera.

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

Langkah 4: Deteksi gestur commit dengan IntersectionObserver

Gunakan IntersectionObserver yang di-root di trek, yang mengamati konten. Saat pengguna menggeser, rasio persimpangan konten dengan jalur akan turun; kami menggunakan dua nilai minimum:

  • activateThreshold: rasio tinggi (misalnya, 0,8). Jika bagian konten yang terlihat turun di bawah nilai ini, pengguna telah melewati titik aktivasi visual. Aktifkan/nonaktifkan affordance pop-up ikon.
  • commitThreshold: rasio rendah (misalnya, 0,2). Jika bagian yang terlihat turun di bawah ini, pengguna telah berkomitmen. Mulai animasi penghapusan segera, tanpa menunggu gestur snap selesai sepenuhnya. Baris yang diciutkan menyatu dengan momentum geser pengguna yang berkelanjutan, yang terasa lebih responsif daripada menunggu snap mendarat sebelum bereaksi.

Dua masalah lainnya ditangani di sini:

  • Penyiapan lambat per item: satu IntersectionObserver luar yang di-root di area pandang mendorong penyiapan dan memulai/menghentikan observer geser dalam. Item hanya akan dihubungkan saat pertama kali di-scroll ke tampilan, dan item yang di-scroll ke luar layar akan dijeda pengamat gesernya. Hal ini membuat jumlah observer aktif tetap terbatas dan menghindari pembacaan nilai yang bergantung pada tata letak (seperti clientWidth) sebelum item dirender.
  • Item dinamis: daftar sebenarnya bertambah seiring waktu (rendering awal, scrolling tanpa batas, server push). MutationObserver di <ul> mendaftarkan item yang baru ditambahkan ke observer luar.
// 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);

Langkah 5: Gunakan tindakan dan label yang sesuai dengan kasus penggunaan Anda

Untuk varian selain penghapusan, hanya visual dan isi pengendali commit yang berubah:

  • Arsipkan: latar belakang hijau/biru, ikon arsip, memindahkan item ke daftar arsip, bukan menghapusnya.
  • Tandai sebagai telah dibaca: latar belakang redup, ikon tanda centang, perbarui status item, dan render ulang (atau cukup hapus class .unread).
  • Tunda: latar belakang biru, ikon jam, sembunyikan hingga waktu yang dipilih.

Mekanisme scroll/snap/pengamatan tidak berubah.

Tindakan yang berbeda per arah geser

Satu baris juga dapat menampilkan dua tindakan berbeda: satu untuk geser ke kiri dan satu untuk geser ke kanan (misalnya, "arsip" di kanan, "hapus" di kiri), seperti yang dilakukan banyak aplikasi email native. Mekanisme scroll, snap, dan deteksi commit tidak berubah sama sekali — satu-satunya hal yang berubah adalah apa yang terjadi pada waktu commit.

Pada Langkah 4, cabang commit selalu memanggil removeItem(...). Untuk mendukung dua tindakan, pilih pengendali berdasarkan direction:

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

Catatan tentang penamaan: removeItem dinamai untuk kasus destruktif, tetapi fungsi yang dijalankannya (menciutkan tinggi baris, menggeser konten ke luar layar, lalu menghapus node) sebenarnya adalah rutinitas umum "baris ini sudah selesai, animasikan". Tindakan ini juga berfungsi untuk arsip, tandai sebagai telah dibaca, atau tunda — baris akan hilang dari daftar ini. Jika pengendali Anda tidak benar-benar menghapus apa pun (misalnya, keduanya memindahkan item ke tempat lain), ganti namanya menjadi sesuatu yang netral seperti dismissItem agar kode terbaca dengan benar.

Agar kedua tindakan tersebut berbeda secara visual, angkat warna dan ikon untuk setiap arah ke item daftar, lalu warnai trek dengan gradien terpisah dan dua ikon elemen semu dari variabel yang sama.

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

Dengan penyiapan ini, spacer tidak lagi memerlukan warna latar belakangnya sendiri (gradien trek menangani pengungkapan), sehingga Anda dapat menghapus aturan background-color di .SwipeableList-track::before, ::after dari Langkah 2 jika Anda menggunakan varian tindakan ganda ini.

Praktik terbaik dan kesalahan umum

  • GUNAKAN snap mandatory, bukan proximity. Dengan proximity, baris dapat berhenti di-scroll sebagian, sehingga latar belakang tindakan terlihat setengah.
  • LAKUKAN setelan overscroll-behavior-x: none di trek. Tanpa ini, geser berlebih dapat memicu gestur navigasi kembali browser di iOS/Android.
  • LAKUKAN commit pada nilai minimum sebelum snap berhenti (misalnya, commitThreshold ≈ 0.2), bukan menunggu konten sepenuhnya berada di luar layar. Hal ini memungkinkan animasi penghapusan dimulai selama gestur, yang terasa jauh lebih responsif daripada menunggu snap mendarat.
  • LAKUKAN penyiapan per item dari area pandang luar IntersectionObserver, bukan menghubungkan setiap item saat pemuatan halaman. Tindakan ini menghindari pembacaan nilai yang bergantung pada tata letak (clientWidth, dll.) sebelum item dirender, dan menjaga jumlah observer aktif tetap proporsional dengan apa yang sebenarnya dapat dilihat pengguna.
  • GUNAKAN MutationObserver pada daftar saat item ditambahkan secara dinamis (rendering awal setelah data dimuat, scrolling tanpa batas, server push). Tanpa itu, item yang ditambahkan setelah pemuatan halaman tidak akan terhubung.
  • JANGAN mengandalkan pointerdown/pointermove/pointerup untuk mendorong transformasi manual. Anda akan kehilangan momentum, snap-back, aksesibilitas keyboard, dan penanganan gerakan yang dikurangi yang diberikan browser secara gratis.
  • LAKUKAN konfirmasi tindakan destruktif jika sesuai. Untuk "hapus", pertimbangkan untuk menampilkan toast urungkan setelah geser selesai; gestur ini cepat dan mudah dipicu secara tidak sengaja.
  • LAKUKAN: pastikan trek scroll dapat difokuskan, dapat diakses keyboard, dan ada affordance fokus visual.
  • LAKUKAN penyediaan alternatif yang mudah diakses untuk tindakan relevan apa pun yang dipicu oleh geser (misalnya, tombol yang terlihat, menu konteks, atau mode edit).

Strategi penggantian

Semua fitur baru yang digunakan bukanlah fitur inti atau memiliki penggantian yang andal dan dapat digunakan sekarang.

Pengganti untuk overscroll-behavior

Tidak diperlukan penggantian untuk kasus penggunaan ini. Meskipun overscroll-behavior memiliki masalah interop yang muncul pada penampung tanpa tambahan yang dapat di-scroll, jalur di sini selalu dapat di-scroll secara horizontal (tiga kolom lebar penuh di dalam penampung lebar 100%), sehingga properti berperilaku secara konsisten di seluruh browser dan gestur geser dapat diandalkan.

Pengganti untuk scrollbar-width

Scrollbar tersembunyi adalah peningkatan visual, bukan mekanisme yang membuat fungsi geser untuk menghapus berfungsi. Jika target Dasar Pengukuran Anda tidak menyertakan scrollbar-width, baris akan tetap di-scroll, dipaskan posisinya, mendeteksi commit, dan dihapus dengan benar; pengalaman yang tidak didukung mungkin hanya menampilkan scrollbar horizontal. Jika produk Anda memerlukan scrollbar tersembunyi di browser lama yang berasal dari WebKit, Anda dapat menambahkan aturan ::-webkit-scrollbar { display: none; } dengan cakupan sempit untuk jalur geser.

Pengganti untuk scroll-initial-target

Jika target Dasar Pengukuran Anda tidak menyertakan scroll-initial-target, scroll trek ke konten secara terprogram di dalam setupItem. Deteksi dengan 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.
}

Urutan panggilan itu penting. Scroll terprogram HARUS berjalan:

  1. AFTER .is-initialized ditambahkan ke item daftar (class inilah yang mengubah trek menjadi penampung scroll; menyetel scrollLeft pada elemen yang tidak dapat di-scroll tidak akan berpengaruh).
  2. SEBELUM IntersectionObserver dalam dari Langkah 4 dipasang. Jika tidak, scroll terprogram awal melewati pengatur jarak kiri akan diamati sebagai "geser" dan segera mengaktifkan pengendali commit.

Menjalankan penyiapan dari observer area pandang luar (Langkah 4) adalah hal yang membuat penyiapan ini andal: setupItem berjalan setelah item dirender, sehingga track.clientWidth menampilkan nilai sebenarnya, bukan 0.

Beberapa browser (terutama Safari) juga mereset posisi scroll penampung snap setiap kali trek diubah ukurannya (menampilkan/menyembunyikan kolom URL, mengubah ukuran area pandang, kueri penampung, dll.). Gunakan ResizeObserver di setiap jalur untuk menerapkan kembali scroll. Tempatkan di balik pemeriksaan CSS.supports yang sama — jika scroll-initial-target didukung, browser akan menangani pemulihan scroll saat pengubahan ukuran dengan sendirinya.

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

Berhenti mengamati trek sebelum animasi tinggi baris berjalan di removeItem. Jika tidak, perubahan tinggi akan memicu kembali callback pengubahan ukuran selama penghapusan. Tambahkan ini bersama viewportObserver.unobserve(item) yang ada di cabang commit:

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