Scroll-driven animation adalah animasi CSS yang maju-mundurnya mengikuti posisi scroll, bukan jam. Ingat flipbook dari pelajaran pertama. Animasi biasa membalik halamannya dengan kecepatan tetap. Scroll-driven animation membiarkan jempolmu yang membalik: geser pelan, gambarnya bergerak pelan; balik arah, gambarnya mundur; berhenti, gambarnya diam di halaman itu.
🧪 Tebakan cepat: Bar progres baca di halaman Bu Sri ditulis
animation: grow linear both; animation-timeline: scroll();. Firefox 146 belum mengenalanimation-timeline. Di browser itu, bar-nya menunjukkan apa? Jawabannya ada di bagian "Dukungan browser dan fallback @supports".
Timeline: jam diganti posisi scroll
Setiap animasi berjalan di sebuah timeline. Defaultnya waktu dokumen: detik demi detik sejak halaman dibuka. Properti animation-timeline menggantinya dengan salah satu dari dua timeline scroll:
| Timeline | Progress 0% | Progress 100% | Contoh di halaman Bu Sri |
|---|---|---|---|
scroll() |
scroller di paling atas | scroller di paling bawah | bar progres baca |
view() |
elemen mulai masuk area yang terlihat | elemen sudah habis keluar | kartu kamar yang muncul |
Scroller adalah elemen yang bisa di-scroll: halamannya sendiri, atau kotak dengan overflow: auto. Karena progress-nya persen, bukan detik, durasinya ditulis auto (atau gak ditulis sama sekali). Kalau kamu baca lewat JavaScript, animation.currentTime juga berupa persen, misalnya 48.76%.
Bar progres baca dengan scroll()
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress {
position: sticky;
top: 0;
height: 4px;
background: #15803d;
transform-origin: 0 50%; /* tumbuh dari kiri */
animation: grow linear both;
animation-timeline: scroll(); /* wajib SETELAH shorthand animation */
}
scroll()sama denganscroll(nearest block): scroller terdekat di atas elemen ini, arah vertikal.scroll(root)selalu memakai halaman.linearmembuat bar persis mengikuti scroll. Denganease, di 25% scroll bar-nya sudah 41% (kami ukur), jadi terasa mendahului.bothmenahan nilai awal dan akhir di luar rentang.
Hasil ukur di Chromium 147 dan WebKit 26.4, scroller 300 px:
| Posisi scroll | scaleX bar |
|---|---|
| 0% | 0 |
| 25% | 0,25 |
| 50% | 0,5 |
| 100% | 1 |
animation-timeline harus ditulis setelah animation, karena shorthand animation mengembalikan animation-timeline ke auto. Kalau urutannya terbalik, animasinya jalan dengan jam dan durasi 0 detik: bar langsung penuh di posisi scroll mana pun (kami lihat di Chromium dan WebKit).
Kartu kamar muncul dengan view() dan animation-range
@keyframes card-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: none; }
}
.room {
animation: card-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
view() membuat timeline untuk elemen itu sendiri saat dia melewati area scroller yang terlihat. animation-range memilih potongan mana dari perjalanan itu yang memutar animasi:
| Range | Mulai | Selesai |
|---|---|---|
entry 0% entry 100% |
tepi atas kartu menyentuh bawah area terlihat | kartu sudah masuk seluruhnya |
cover 0% cover 50% |
kartu mulai masuk | kartu di tengah area terlihat |
contain 0% contain 100% |
kartu sudah masuk seluruhnya | kartu mulai keluar |
exit 0% exit 100% |
kartu mulai keluar | kartu sudah habis keluar |
Kartu Kamar Loteng tingginya 120 px. Dengan entry 0% entry 100%, kami ukur opacity-nya: 0 saat baru menyentuh, 0,25 saat 30 px masuk, 0,5 saat 60 px, 1 saat 120 px. Scroll balik ke atas, kartunya memudar lagi, karena timeline-nya ikut mundur. Mau muncul sekali saja dan tetap tampil? Itu tugas IntersectionObserver, di pelajaran berikutnya.
Tabel gerak kita menulis kartu masuk 300 ms. Di sini gak ada durasi: jarak scroll yang jadi jamnya.
Jalan di luar main thread
Versi lama bar progres memakai addEventListener('scroll', ...): JavaScript membaca scrollTop lalu menulis transform di setiap event, di main thread. Kalau main thread sibuk, bar-nya ikut tersendat; dari pelajaran pertama, animasi yang digerakkan main thread cuma dapat 1 frame selama main thread sibuk 500 ms.
Scroll-driven animation gak menjalankan JavaScript sama sekali. Browser sendiri menghubungkan posisi scroll ke animasi, dan menurut tim Chrome, animasi yang cuma mengubah transform dan opacity dijalankan di luar main thread, seperti CSS transition. Jadi tetap pakai dua properti itu.
Dukungan browser dan fallback @supports
Data MDN per Oktober 2026, dicocokkan dengan browser yang kami tes:
| Browser | animation-timeline, scroll(), view(), animation-range |
|---|---|
| Chrome, Edge | sejak versi 115 (Juli 2023) |
| Safari (macOS dan iOS) | sejak versi 26 |
| Firefox | belum di versi rilis; baru menyala di Firefox Nightly. Di Firefox 146 kami, CSS.supports('animation-timeline: scroll()') bernilai false |
Itu sebabnya fallback wajib. Di Firefox 146, animation-timeline diabaikan, sisanya jadi animasi biasa berdurasi 0 detik dengan both: langsung loncat ke keadaan akhir. Itu jawaban tebakannya: bar-nya penuh 100% sejak halaman dibuka, di posisi scroll mana pun. Kartu kamarnya kebetulan aman: langsung terlihat.
Bungkus semuanya dalam @supports, supaya browser lain dapat halaman yang wajar:
.progress { display: none; } /* tanpa dukungan: tanpa bar */
@supports (animation-timeline: scroll()) {
.progress {
display: block;
animation: grow linear both;
animation-timeline: scroll();
}
.room {
animation: card-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
Kalau bar-nya penting, isi fallback-nya dengan JavaScript: listener scroll yang passive, dan satu requestAnimationFrame per frame:
if (!CSS.supports('animation-timeline: scroll()')) {
const bar = document.querySelector('.progress');
bar.style.display = 'block';
let ticking = false;
addEventListener('scroll', () => {
if (ticking) return; // sudah ada update yang menunggu frame
ticking = true;
requestAnimationFrame(() => {
const max = document.documentElement.scrollHeight - innerHeight;
bar.style.transform = `scaleX(${scrollY / max})`;
ticking = false;
});
}, { passive: true }); // janji gak memanggil preventDefault()
}
Untuk memunculkan kartu tanpa event scroll sama sekali, API yang dipakai adalah IntersectionObserver: browser yang memberi tahu saat elemen masuk area terlihat.
Kurangi gerakan
Untuk yang menyalakan "Kurangi gerakan", buang geraknya, sisakan fade:
@keyframes card-fade { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.room { animation-name: card-fade; } /* gak naik 16 px, cuma memudar */
}
⚠️ Jebakan yang sering ketemu
animation-timelineditulis sebelumanimation. Shorthand-nya mengembalikan timeline keauto: animasinya jalan 0 detik dengan jam, bar langsung penuh.- Tanpa
@supports. Di Firefox rilis, bar progres tampil penuh 100% sepanjang waktu. - Ada
overflow: hiddendi antara bar dan scroller-nya. Elemen itu jadi scroller terdekat yang gak pernah bergerak; kami ukurcurrentTime-nyanulldan bar-nya diam. Pakaiscroll(root)atau pindahkan bar-nya. - Pakai
easeuntuk progres. Bar mendahului scroll (41% di 25%). Progres pakailinear. - Menganimasikan
topatauwidth. Di luartransformdanopacity, animasinya kembali butuh main thread.
TL;DR: animation-timeline mengganti jam dengan posisi scroll: scroll() mengikuti scroller (0% di atas, 100% di bawah, untuk bar progres), view() mengikuti perjalanan elemen melewati area terlihat (untuk kartu yang muncul), dan animation-range memilih potongannya (entry 0% entry 100%: dari mulai masuk sampai masuk seluruhnya; kartu 120 px punya opacity 0,5 saat 60 px masuk). Tulis animation-timeline setelah animation, durasi auto, easing linear, dan cuma transform/opacity supaya jalan di luar main thread. Didukung Chrome 115 dan Safari 26; Firefox rilis belum (baru Nightly), dan di sana bar tanpa @supports tampil penuh. Bungkus dengan @supports (animation-timeline: scroll()), isi fallback dengan listener scroll passive + requestAnimationFrame, atau IntersectionObserver untuk memunculkan elemen. Dengan "Kurangi gerakan", buang geraknya dan sisakan fade.