Ini salah satu bug animasi paling menyebalkan yang pernah saya kejar, karena ia muncul belakangan — bukan saat load, tapi setelah aset berat selesai diunduh. Di sebuah situs cinematic dengan hero frame-sequence (ratusan frame), semua reveal dan clip-animation di section bawah hero tiba-tiba meleset: trigger-nya nyala di posisi yang salah, sebagian tidak nyala sama sekali. Di awal semuanya normal.
Setelah berjam-jam, akar masalahnya: GSAP pin: true menyuntikkan pin-spacer secara runtime, dan itu mengubah tinggi dokumen setelah halaman sudah ter-layout.
Kenapa pin: true bikin semuanya meleset
Saat Anda mem-pin hero dengan ScrollTrigger (pin: true), GSAP membungkus elemen itu dengan sebuah pin-spacer — div tambahan yang menahan ruang selama hero "menempel". Spacer ini ditambahkan saat runtime, dan tingginya bisa berubah.
Masalahnya muncul dengan aset yang loading lama (frame sequence, gambar besar): begitu aset selesai dan hero mendapat dimensi final, pin-spacer tumbuh — menambah ratusan piksel ke tinggi dokumen. Tapi semua ScrollTrigger di bawahnya sudah terlanjur menghitung posisi start/end mereka terhadap layout sebelum pin. Hasilnya: semuanya bergeser.
Dan kalau Anda pakai Lenis (smooth scroll), makin parah: Lenis menyimpan batas scroll secara independen dan tidak otomatis menyesuaikan saat pin-spacer tumbuh — jadi smooth scroll dan ScrollTrigger jadi tidak sinkron.
Yang bikin frustrasi: ScrollTrigger.refresh() pun sering tidak cukup memulihkannya, apalagi dengan Lenis di tengah. Saya sudah coba invalidateOnRefresh + setTimeout(refresh, 50) — tetap drift.
Solusi: pakai CSS position: sticky, bukan GSAP pin
Daripada membiarkan GSAP memanipulasi DOM saat runtime, buat ruang scroll-nya lewat CSS sejak awal sehingga tinggi dokumen tidak pernah berubah:
/* Container luar menyediakan tinggi scroll dari awal (t=0) */
.hero-stage {
height: calc(100svh + 1400px); /* 1400px = jarak scroll efek hero */
}
/* Elemen dalam yang "menempel" pakai sticky native */
.hero-sticky {
position: sticky;
top: 0;
height: 100svh;
}Lalu ScrollTrigger hanya membaca progress, tanpa pin sama sekali:
gsap.to(heroAnim, {
// ...properti animasi
scrollTrigger: {
trigger: ".hero-stage",
start: "top top",
end: "bottom bottom",
scrub: true, // hanya scrub, TANPA pin: true
},
});Karena tinggi dokumen sudah pasti sejak awal (dari calc()), pin-spacer tidak pernah disuntikkan, tinggi tidak pernah berubah saat aset loading, dan semua ScrollTrigger di bawah hero menghitung posisi terhadap layout yang stabil. Lenis pun tidak perlu re-measure.
Pola ini sudah saya pakai berulang di proyek React maupun vanilla, dan hasilnya konsisten: scroll effect hero yang panjang lebih baik pakai native sticky daripada GSAP pin.
Catatan tambahan
- Pakai
svh(small viewport height), bukanvh, supaya tidak melonjak saat address bar mobile muncul/hilang. - Kalau butuh frame-0 langsung tergambar sebelum scroll, paksa satu
requestAnimationFrame(draw)saat load — jangan tunggu event scroll pertama. - Kalau tetap mau pakai GSAP pin (misal untuk horizontal scroll), pastikan semua aset yang memengaruhi tinggi sudah dimuat sebelum membuat ScrollTrigger, lalu panggil
ScrollTrigger.refresh()sekali setelahnya.
Intinya: begitu sebuah library mengubah tinggi dokumen saat runtime, semua perhitungan scroll yang bergantung padanya jadi rapuh. CSS sticky memindahkan "ruang scroll" itu ke layout yang deklaratif dan stabil — dan bug "animasi bawah hero meleset setelah loading" pun hilang.
