Infinite Vertical Bento Grid
Galeri mosaik 25 foto yang bergulir vertikal tanpa ujung menggunakan teknik kloning tiga lapis dan requestAnimationFrame.
Membaca sekitar 2 menit
Galeri mosaik 25 foto yang bergulir vertikal tanpa ujung menggunakan teknik kloning tiga lapis dan requestAnimationFrame.
Menyusun 25 foto ke dalam CSS Grid 3 kolom × 15 baris tanpa celah kosong, menggandakannya menjadi 3 salinan identik, lalu menggeser scrollTop secara kontinu dengan pelompatan koordinat tak kasatmata.
Menggabungkan gulir otomatis berbasis delta-time dengan gulir manual pengguna menuntut sinkronisasi posisi di satu ref yang sama agar lompatan siklus tidak terlihat mata.
Masalahnya dulu
Di halaman /gallery, terdapat 25 foto pre-wedding dari sesi studio, mercusuar, dan pantai. Jika ditampilkan sebagai daftar biasa, halaman akan terasa panjang dan membosankan. Saya menginginkan tampilan dinding mosaik (bento grid) dengan ukuran kotak bervariasi yang mengalir turun secara otomatis tanpa pernah habis, namun tetap mengizinkan pengguna menggulir cepat dengan jari kapan saja.
Tiga salinan DOM dan pelompatan koordinat tak kasatmata
Alih-alih memakai pustaka carousel berat yang sering bermasalah saat menggabungkan auto-scroll dengan tata letak CSS Grid asimetris, GallerySwiper.tsx memetakan 25 foto ke dalam matriks 3 kolom × 15 baris (tinggi tiap baris 130px dengan celah 4px). Satu blok utuh memiliki tinggi pasti SINGLE_HEIGHT. Komponen kemudian merender 3 salinan blok tersebut secara berurutan (copy-a, copy-b, copy-c) dan memulai posisi scroll tepat di salinan tengah (copy-b). Loop requestAnimationFrame menggeser scrollTop sebesar 38 piksel per detik, dan setiap kali posisi melewati batas salinan tengah, koordinatnya dikurangi atau ditambah sebesar SINGLE_HEIGHT secara instan.
Ilusi tak terbatas lahir dari tiga salinan identik: selama pengguna dijaga tetap berada di salinan tengah, ujung atas dan bawah tidak akan pernah tercapai.
Implementasi di ngodingan ini
Beginilah mekanisme loop menggunakan delta-time dan pembungkus koordinat manual di src/components/GallerySwiper.tsx:
const ROW_UNIT = 130;
const GAP = 4;
const TOTAL_ROWS = 15;
const SINGLE_HEIGHT = TOTAL_ROWS * (ROW_UNIT + GAP) - GAP;
const runScroll = useCallback((ts: number) => {
if (!lastTimeRef.current) lastTimeRef.current = ts;
const dt = (ts - lastTimeRef.current) / 1000;
lastTimeRef.current = ts;
if (!pausedRef.current && scrollRef.current) {
posRef.current += SPEED * dt;
if (posRef.current >= SINGLE_HEIGHT * 2) {
posRef.current -= SINGLE_HEIGHT;
}
scrollRef.current.scrollTop = posRef.current;
}
autoScrollRef.current = requestAnimationFrame(runScroll);
}, [SINGLE_HEIGHT]);
const onScroll = useCallback(() => {
if (!scrollRef.current) return;
const y = scrollRef.current.scrollTop;
posRef.current = y;
if (y < SINGLE_HEIGHT * 0.5) {
posRef.current = y + SINGLE_HEIGHT;
scrollRef.current.scrollTop = posRef.current;
} else if (y >= SINGLE_HEIGHT * 2) {
posRef.current = y - SINGLE_HEIGHT;
scrollRef.current.scrollTop = posRef.current;
}
}, [SINGLE_HEIGHT]);Jebakan dan detail yang harus dijaga
- Menghitung pergerakan dengan selisih waktu nyata (dt = (ts - lastTimeRef.current) / 1000) wajib dilakukan agar kecepatan gulir (38 px/detik) tetap sama di layar 60Hz maupun 120Hz ProMotion.
- Saat pengguna menggulir secara manual dengan jari, event onScroll harus langsung memperbarui posRef.current. Jika tidak disinkronkan, frame requestAnimationFrame berikutnya akan menarik layar kembali ke posisi lama dan menyebabkan efek bergetar.