Memicu Efek Setelah Scrolling Terprogram Selesai

Saat men-scroll secara terprogram ke bagian atau elemen (terutama dengan behavior: 'smooth'), memicu tindakan lanjutan segera—seperti mengedipkan status sorotan, memulai animasi masuk, atau memindahkan fokus—akan menyebabkan efek berjalan saat target masih di luar layar atau di tengah scroll.

Sebelumnya, developer harus menebak durasi scroll dengan setTimeout() (yang diaktifkan terlalu awal pada scroll panjang dan terlalu lambat pada scroll pendek) atau menggabungkan pemroses peristiwa scrollend secara manual dalam Promise dengan timer penggantian (karena scrollend tidak diaktifkan saat target sudah terlihat dan tidak ada scroll yang terjadi).

Metode scroll terprogram di Element (scrollIntoView(), scroll(), scrollTo(), scrollBy()) dan Window (scroll(), scrollTo(), scrollBy()) menampilkan Promise yang di-resolve saat operasi scroll selesai. Jika target sudah berada di posisi scroll yang diminta dan tidak perlu di-scroll, Promise yang ditampilkan akan segera di-resolve tanpa hang.

Penerapan

  1. Tandai pengendali sebagai async: Gunakan fungsi async untuk pemroses peristiwa atau pengendali navigasi yang memicu scroll.
  2. Menunggu metode scroll secara langsung: Panggil await element.scrollIntoView({ behavior: 'smooth', ... }) (atau await element.scrollTo(...), await window.scrollTo(...), dll.). Eksekusi dijeda hingga browser menyelesaikan animasi scroll dan posisi scroll ditetapkan.
  3. Terapkan efek pasca-scroll: Alihkan class sorotan, mulai animasi, atau pindahkan fokus setelah ekspresi await selesai.

Scroll Terganggu & Pengelolaan Fokus

  • Menangani scroll cepat/terganggu: Jika scroll baru dimulai pada penampung scroll yang sama sebelum scroll halus yang sedang berlangsung selesai, browser akan membatalkan scroll halus yang sedang berlangsung dan menyelesaikan (tidak menolak) semua promise scroll yang tertunda di penampung tersebut. Jika Anda perlu menyembunyikan efek pasca-scroll untuk scroll yang digantikan saat pengguna mengklik dengan cepat, lacak ID operasi yang bertambah dan kembali lebih awal setelah await jika scroll yang lebih baru dimulai:
let currentScrollId = 0;
const sections = document.querySelectorAll('section');

async function scrollToAndHighlight(targetElement) {
  const scrollId = ++currentScrollId;
  sections.forEach((sec) => sec.classList.remove('highlight'));

  await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });

  // Abort follow-up effect if another scroll was triggered while awaiting
  if (scrollId !== currentScrollId) return;

  targetElement.classList.add('highlight');
}
  • Memfokuskan target setelah men-scroll: Saat memindahkan fokus keyboard ke elemen target (atau judul di dalamnya dengan tabindex="-1") setelah await targetElement.scrollIntoView(...), teruskan { preventScroll: true } ke focus() agar panggilan fokus tidak memulai scroll kedua yang bersaing:
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });

Batasan & Praktik Terbaik

  • WAJIB: Tunggu Promise yang ditampilkan langsung oleh scrollIntoView(), scroll(), scrollTo(), atau scrollBy(), bukan menebak durasi scroll dengan setTimeout().
  • JANGAN menggabungkan scrollend dalam new Promise() manual sebagai implementasi utama Anda—scrollend tidak diaktifkan saat target sudah terlihat dan tidak ada jarak scroll yang ditempuh, sedangkan promise metode scroll akan otomatis di-resolve dalam kasus tersebut.
  • LAKUKAN: Teruskan { preventScroll: true } saat memanggil .focus() setelah menunggu metode scroll agar browser tidak melompat atau men-scroll untuk kedua kalinya.
  • LAKUKAN: Lindungi efek pasca-scroll dengan token permintaan jika interaksi pengguna yang cepat dapat memicu scroll halus yang tumpang-tindih pada penampung yang sama, karena menginterupsi scroll halus akan menyelesaikan (bukan menolak) promise sebelumnya.
  • Bug Chrome umum dengan root scroller: Chrome memiliki bug yang menyebabkan Promise yang ditampilkan oleh scrollIntoView() di-resolve terlalu awal saat root scroller dokumen di-scroll. Menunggu scrollIntoView() pada penampung scroll bertingkat berfungsi seperti yang diharapkan. Jangan gabungkan konten Anda dalam penampung scroll, seperti div dengan overflow-y: auto, sebagai solusi untuk bug ini.

Strategi penggantian

Di browser yang tidak mendukung promise metode scroll, scrollIntoView(), scroll(), scrollTo(), dan scrollBy() menampilkan undefined, bukan Promise.

  • Progressive enhancement (perilaku await default): Karena await undefined adalah JavaScript valid yang langsung di-resolve pada microtask berikutnya, penulisan await element.scrollIntoView({ behavior: 'smooth' }) tidak akan menampilkan error di browser yang tidak didukung. Sebagai gantinya, browser akan menurun secara halus dengan menjalankan kode pasca-scroll segera setelah scroll dimulai. Untuk hiasan visual yang tidak penting seperti penyorotan sementara, hal ini sering kali sudah cukup.
  • Penggantian yang akurat (scrollend + waktu tunggu): Jika target Dasar Pengukuran Anda tidak mendukung scroll-promises dan tindakan setelah scroll harus menunggu hingga scroll selesai di browser lama, periksa apakah nilai yang ditampilkan adalah Promise (result instanceof Promise). Jika nilainya undefined, tunggu peristiwa scrollend di penampung scroll dengan waktu tunggu penggantian singkat jika elemen sudah terlihat dan tidak ada scroll yang terjadi:
function scrollIntoViewAsync(element, options, scroller = window) {
  const result = element.scrollIntoView(options);

  // Feature detect: in supporting browsers, scrollIntoView() returns a Promise
  if (result instanceof Promise) {
    return result;
  }

  // Fallback for browsers where scrollIntoView() returns undefined
  return new Promise((resolve) => {
    let timeoutId;

    const finish = () => {
      clearTimeout(timeoutId);
      scroller.removeEventListener('scrollend', finish);
      scroller.removeEventListener('scroll', onScroll);
      resolve();
    };

    const onScroll = () => {
      // Restart the idle timer on every scroll event. If scrollend never fires
      // (browsers without scrollend support), this resolves once scrolling goes idle.
      clearTimeout(timeoutId);
      timeoutId = setTimeout(finish, 100);
    };

    scroller.addEventListener('scrollend', finish, { once: true });
    scroller.addEventListener('scroll', onScroll, { passive: true });

    // If no scroll event fires within the idle window (e.g., already in view), resolve.
    // 100ms is an example value; it only needs to exceed the gap between scroll events.
    timeoutId = setTimeout(finish, 100);
  });
}