D
P
0

CSS & Web Animation

Teks Putih di Hero Lenyap Saat Scroll-Up Cepat? `onEnter`/`onLeave` ScrollTrigger Balapan Nyopot `scene-dark`

28 Juli 2026·5 menit baca
Teks Putih di Hero Lenyap Saat Scroll-Up Cepat? `onEnter`/`onLeave` ScrollTrigger Balapan Nyopot `scene-dark`

Saya bangun homepage immersive untuk sebuah situs klien: satu section panjang yang di-pin, dan tema warnanya berganti mengikuti posisi scroll. Hero-nya gelap, bagian tengah terang, lalu section services balik gelap lagi. Seluruh pergantian gelap-terang itu dikendalikan satu class di body. Kalau scene-dark nyala, background gelap dan teks di-set putih; kalau mati, background terang. Selama tema dan warna teks sinkron, transisinya mulus dan enak dilihat.

Lalu datang bug yang bikin saya melongo. Kadang, pas pengunjung scroll ke atas dengan cepat, subtitle di hero mendadak lenyap. Bukan pudar, benar-benar tak terbaca. Setelah saya perhatikan: background hero-nya jadi putih, padahal subtitle-nya di-hardcode warna putih. Putih di atas putih. Elemennya masih ada di DOM, ukurannya normal, cuma tak terlihat sama sekali.

Gejalanya

Yang bikin ini menyebalkan: intermittent, dan pola pemicunya spesifik. Scroll pelan ke bawah, aman. Scroll pelan ke atas, aman. Tapi scroll ke atas dengan CEPAT, dari area services balik ke hero, kadang scene-dark copot dan hero jadi putih. Refresh kadang menyembuhkan, kadang tidak. Tidak ada error di console, tidak ada warning. Cuma teks yang hilang.

Jalan buntu yang saya coba

Refleks pertama saya yang salah: mengira ini masalah warna. Ya sudah, jangan hardcode subtitle putih, bikin dia adaptif. Tapi itu cuma menambal gejala. Kalau scene-dark memang seharusnya nyala di hero, memaksa teks jadi gelap malah bikin salah warna di kondisi normal. Saya tahan.

Dugaan kedua: transisi CSS pada body yang telat. Saya matikan transition-nya, bug tetap ada. Bukan itu.

Baru setelah saya pasang console.log di dalam setiap callback ScrollTrigger, polanya kelihatan. Pas fast scroll-up, log-nya keluar dengan urutan yang tidak saya duga: handler yang MENAMBAH scene-dark jalan lebih dulu, lalu sepersekian detik kemudian handler lain MENGHAPUS-nya lagi. Class-nya sempat benar, lalu dicopot oleh callback yang telat.

Akar masalahnya: dua ScrollTrigger saling balapan

Waktu itu tiap section mengurus tema-nya sendiri. Hero punya ScrollTrigger dengan onEnter, onLeave, onEnterBack, onLeaveBack. Services punya set callback edge yang sama. Empat callback tersebar di dua fungsi, masing-masing sibuk menambah atau menghapus scene-dark di batas section-nya sendiri.

Selama scroll pelan, batas-batas itu dilewati satu per satu dengan rapi, jadi urutan callback masuk akal. Masalahnya muncul karena halaman ini pakai Lenis untuk smooth scroll. Pas fast scroll-up, Lenis bisa memindahkan posisi scroll cukup jauh dalam satu frame sampai dua batas terlewati hampir bersamaan. Dan di sinilah petakanya: urutan fire antara leave services dan enter hero tidak dijamin atomik. Callback enter hero menambah scene-dark, lalu callback leave services yang menyusul menghapusnya, padahal saya sudah di dalam hero. Hasilnya: hero tanpa scene-dark, background putih, teks putih hilang.

Intinya bukan salah satu callback yang bugnya. Masalahnya arsitektur: edge callback menyimpan state secara implisit lewat urutan kejadian. Begitu urutan tidak dijamin, state-nya rusak.

Perbaikannya: satu state controller dari posisi scroll

Solusinya bukan menambal urutan, tapi membuang konsep urutan sepenuhnya. Saya ganti empat ScrollTrigger yang tersebar itu dengan SATU ScrollTrigger yang tidak peduli edge sama sekali. Dia cuma memantau posisi scroll dan menghitung ulang state dari nol tiap update:

function initSceneController() {
  const HERO_END = 2500;
  const SERVICES_START = 9400;
  const SERVICES_END = 14000;
 
  const apply = (scroll) => {
    const inHero = scroll < HERO_END;
    const inServices = scroll >= SERVICES_START && scroll < SERVICES_END;
    body.classList.toggle("scene-dark", inHero || inServices);
    if (stage) stage.classList.toggle("is-dark", inServices);
  };
 
  ScrollTrigger.create({
    trigger: "body",
    start: "top top",
    end: "max",
    onUpdate: (self) => apply(self.scroll()),
    onRefresh: (self) => apply(self.scroll()),
  });
 
  apply(window.scrollY);
}

Perhatikan toggle yang argumen keduanya boolean. scene-dark nyala kalau dan hanya kalau posisi scroll ada di hero atau services. Tidak ada lagi tambah di sini, hapus di sana. State-nya fungsi murni dari satu angka: posisi scroll. Mau frame-nya lompat sejauh apa pun pas fast scroll-up, hasilnya selalu benar karena dihitung dari kondisi sekarang, bukan dari akumulasi event. Balapan-nya hilang karena tidak ada lagi dua pihak yang berebut menulis class yang sama.

Versi minimal ini yang saya ship duluan. Godaan untuk langsung menulis ulang semua reveal jadi satu sistem besar sempat saya turuti, dan big-bang refactor itu malah saya revert karena kebanyakan berubah sekaligus. Baru setelah itu saya generalisasi pelan-pelan.

Generalisasi jadi state controller penuh

Pola yang sama saya angkat untuk SEMUA reveal teks. Saya bikin satu registry state plus daftar batas fase, lalu satu fungsi yang menerapkan fase dan hanya menembakkan show atau hide saat fase BERUBAH:

const TEXT_STATE = { hero, subtitle, problem, approach: [null, null, null] };
const PHASE_BOUNDARIES = [400, 1100, 3400, 5400, 7400, 9400];
 
const getPhase = (scroll) =>
  PHASE_BOUNDARIES.filter((b) => scroll >= b).length;
 
let currentPhase = -1;
const applyPhase = (scroll) => {
  const target = getPhase(scroll);
  if (target === currentPhase) return; // cuma jalan saat fase berubah
  currentPhase = target;
  // show/hide teks sesuai target
};

Karena state selalu dihitung dari posisi scroll dan aksi cuma jalan saat fase berubah, generalisasi ini sekaligus membunuh dua bug lain: phantom callback yang fire pas init, dan reveal yang kacau saat scroll-up reversal. Semua itu gejala dari penyakit yang sama: menyimpan state di edge callback alih-alih menghitungnya.

Catatan

  • Kalau class tema copot cuma pas fast scroll-up, curigai race antar callback edge, bukan CSS atau warna.
  • onEnter, onLeave, onEnterBack, onLeaveBack menyimpan state lewat urutan kejadian. Smooth scroll seperti Lenis bisa melewati beberapa batas dalam satu frame, dan urutan fire-nya tidak dijamin atomik.
  • Ganti banyak edge callback dengan satu ScrollTrigger onUpdate yang menghitung state sebagai fungsi murni dari posisi scroll. Tidak ada urutan, tidak ada race.
  • Pakai classList.toggle(name, boolean) biar state selalu ditulis eksplisit dari kondisi sekarang, bukan ditambah dan dihapus terpisah.
  • State controller mengalahkan edge callback: sekali angka scroll jadi satu-satunya sumber kebenaran, phantom init fire dan bug scroll-up reversal ikut hilang.
  • Jangan langsung big-bang refactor. Ship state controller minimal dulu, generalisasi belakangan.