Bug ini masuk dari klien dengan deskripsi yang sebenarnya sudah sangat akurat: "animasinya snap, bukan tween". Saya lagi menggarap landing page pra-peluncuran sebagai tema WordPress classic, dengan anime.js 4.5.0 yang saya vendor sendiri sebagai file UMD dan satu file JS yang menampung semua efeknya. Semua animasi masuk dipicu scroll lewat IntersectionObserver. Di kepala saya polanya sudah paling rapi: bikin semua animasi di awal dengan autoplay: false, lalu tinggal .play() waktu sectionnya kelihatan.
Ternyata justru pola itu yang salah.
Gejalanya: elemennya muncul, tapi tanpa perjalanan
Yang bikin bug ini agak licin, dia tidak terlihat seperti animasi rusak. Elemennya tetap muncul. Opacity-nya benar, posisi akhirnya benar, tidak ada yang hilang atau nyangkut di layar. Yang hilang cuma bagian tengahnya.
Begitu section masuk viewport, teks dan kartunya langsung ada di posisi final. Tidak ada fade, tidak ada geser dari bawah, tidak ada easing. Nol milidetik. Kalau kamu scroll cepat, kamu bahkan bisa bilang "ya sudah muncul kok". Baru kalau scroll pelan-pelan kelihatan bahwa tidak pernah ada transisi sama sekali.
Tersangka pertama saya yang biasa biasa saja: duration kepencet nol, nama easing salah ketik, atau ada transition dari CSS tema yang bentrok. Semuanya bersih. Nilai duration masuk akal, easing-nya valid, dan tidak ada properti yang sama diperebutkan CSS.
Akar masalahnya: clock-nya terikat saat animasi dibuat
anime.js v4 mengikat animasi ke clock engine-nya pada saat animasi dibuat, bukan pada saat kamu memanggil .play().
Jadi begitu baris animate(...) dieksekusi, animasinya sudah punya titik nol di timeline global. autoplay: false cuma bilang "jangan render dulu", dia tidak menahan waktu supaya berhenti berjalan. Waktu tetap jalan terus selama pengunjung membaca hero, membaca section berikutnya, dan seterusnya.
Waktu akhirnya .play() dipanggil karena section-nya masuk layar, engine melihat animasi yang durasinya, katakanlah, 800 ms, tapi sudah lewat belasan detik sejak titik nolnya. Kesimpulannya wajar dari sisi engine: animasi ini sudah selesai. Yang di-render langsung frame terakhir.
Makanya hasilnya snap ke end-state. Bukan easing-nya yang gagal, tapi memang tidak ada sisa durasi yang perlu ditween.
Ini pola yang bikin salah paham gara-gara kebiasaan lama. Di banyak library, bikin objek animasi lalu memutarnya nanti itu wajar. Di v4, jarak antara "dibuat" dan "diputar" itu bukan jeda netral, dia dihitung.
Bentuk yang bikin celaka:
const { animate } = window.anime;
// dibuat sekali di awal, waktu file efeknya pertama jalan
const reveal = animate('.section-copy', {
opacity: [0, 1],
translateY: [24, 0],
duration: 800,
ease: 'outQuad',
autoplay: false,
});
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) reveal.play(); // langsung snap
});
}, { threshold: 0.2 });
io.observe(document.querySelector('.section-copy'));Kode ini terbaca benar. Dia bahkan terasa lebih efisien karena objek animasinya dibuat sekali. Masalahnya jam sudah mulai berdetak dari baris animate(...), dan pengunjung butuh belasan detik untuk sampai ke section itu.
Perbaikannya: bangun animasinya di dalam callback, saat dipicu
Aturannya jadi sederhana: jangan pernah membuat animasi lebih awal daripada saat kamu benar-benar ingin dia jalan. Pindahkan pembuatannya ke dalam callback IntersectionObserver, jalankan sekali, lalu lepaskan observernya.
Konsekuensinya, kondisi tersembunyi sebelum animasi jalan tidak boleh lagi bergantung pada objek animasi, karena objeknya belum ada. Kondisi itu saya set sebagai inline style secara sinkron, sebelum observer dipasang.
const { animate } = window.anime;
const targets = document.querySelectorAll('[data-reveal]');
// 1. pre-state sinkron lewat inline style, bukan lewat objek animasi
targets.forEach((el) => {
el.style.opacity = '0';
el.style.transform = 'translateY(24px)';
});
let remaining = targets.length;
const io = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
// 2. animasinya baru dibuat di sini, tepat saat elemennya masuk layar
animate(entry.target, {
opacity: [0, 1],
translateY: [24, 0],
duration: 800,
ease: 'outQuad',
});
// 3. sekali jalan saja, lalu lepas
observer.unobserve(entry.target);
remaining -= 1;
if (remaining === 0) observer.disconnect();
});
}, { threshold: 0.2 });
targets.forEach((el) => io.observe(el));Tiga hal yang membuat versi ini benar:
Animasi dibuat saat dipicu. Titik nol clock-nya sekarang berimpit dengan momen elemennya masuk viewport, jadi seluruh 800 ms itu masih tersedia untuk ditween. Tidak ada waktu yang habis di belakang layar.
Pre-state lewat inline style yang sinkron. Karena animasinya belum ada sebelum trigger, harus ada yang memegang kondisi tersembunyi. Inline style yang di-set langsung saat skrip jalan menutup celah itu tanpa perlu menunggu apa pun. Bonusnya: yang menyembunyikan elemen sekarang adalah JavaScript itu sendiri, jadi kalau skripnya gagal jalan, konten tetap tampil apa adanya, bukan hilang permanen.
Sekali jalan lalu disconnect. Tanpa unobserve, elemen yang keluar masuk viewport akan bikin animasi baru berulang kali. Tiap animasi baru artinya titik nol baru, dan itu membuka pintu ke keanehan lain yang tidak perlu.
Setelah dipasang, saya verifikasi langsung di landing yang sudah tayang lewat Playwright: fade dan geser dari bawahnya jalan penuh, easing-nya terasa, dan tidak ada lagi elemen yang lompat ke posisi akhir.
Yang saya bawa pulang
- Di anime.js v4,
autoplay: falsebukan tombol pause. Clock engine sudah terikat sejak animasi dibuat, jadi menunda.play()sama saja membakar durasinya. - Kalau animasi dipicu scroll, waktu pembuatan dan waktu pemicuan harus jadi satu momen yang sama. Bangun di dalam callback, bukan di atas file.
- Kondisi awal yang tersembunyi adalah tanggung jawab inline style yang sinkron, bukan tanggung jawab objek animasi yang belum lahir.
- Snap ke end-state hampir tidak pernah berarti easing-nya salah. Biasanya artinya engine yakin animasinya sudah selesai, dan pertanyaan yang benar adalah kapan jam animasi itu mulai berdetak.