Bug ini tidak melempar apa pun. Tidak ada error, tidak ada warning, tidak ada angka merah. Yang ada cuma satu kartu yang selalu berhenti di tempat yang salah, sekitar sejengkal terlalu ke kanan, setiap kali animasinya selesai.
Konteksnya section penjelasan produk yang panjang di sebuah toko online, dibuat bergaya halaman spesifikasi: section di-pin di layar, lalu tiga kartu bergeser ke samping mengikuti scroll vertikal pengunjung. Kartu 01 dan 02 lewat dengan mulus. Kartu 03 tidak pernah benar-benar lepas.
Gejalanya: kartu 03 mentok di garis potong
Di akhir pan, tepi kanan kartu 03 mendarat persis di batas kliping. Sudut membulatnya terpangkas, bayangannya hilang, dan kartu itu terlihat seperti dipotong pisau. Dua kartu sebelumnya punya jarak lega ke tepi layar. Yang terakhir tidak dapat jatah itu sama sekali.
Strukturnya sesederhana ini:
<section class="hscroll" data-hscroll>
<div class="hscroll__track" data-hscroll-track>
<article class="hscroll__card">01</article>
<article class="hscroll__card">02</article>
<article class="hscroll__card">03</article>
</div>
</section>[data-hscroll] {
overflow: hidden;
}
.hscroll__track {
display: flex;
gap: 32px;
padding-inline: 6vw; /* jarak nafas kiri dan kanan */
will-change: transform;
}
.hscroll__card {
flex: 0 0 520px;
border-radius: 24px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.12);
}Dan JavaScript-nya memakai rumus yang muncul di hampir semua tutorial horizontal scroll:
const distance = track.scrollWidth - track.clientWidth;
function onScroll(progress) {
track.style.transform = `translate3d(${-distance * progress}px, 0, 0)`;
}Kelihatan masuk akal. scrollWidth itu seberapa lebar isinya, clientWidth itu seberapa lebar yang kelihatan, selisihnya jarak yang perlu ditempuh. Rumus itu benar untuk container yang beneran di-scroll browser. Untuk track yang digeser sendiri pakai transform, dia bohong.
Tersangka pertama yang salah: panjang pin
Tebakan pertama saya adalah section-nya kurang panjang, jadi pan-nya keburu habis sebelum kartu terakhir sempat masuk. Saya tambah tinggi area pin dari 300vh ke 500vh.
Hasilnya: pan jadi lebih lambat, dan kartu 03 tetap berhenti di titik yang sama persis.
Ini sebenarnya diagnostik yang berguna, dan saya sarankan kamu pakai duluan setiap kali bertemu scroll-lock yang berhenti di tempat salah. Kalau kamu memanjangkan area scroll dan titik akhirnya tidak bergeser satu piksel pun, artinya masalahnya bukan di pemetaan scroll ke progress. Progress-nya sampai ke 1 dengan baik. Yang salah adalah angka yang dikalikan dengan progress itu. Plafonnya memang ada di distance.
Jadi saya berhenti mengutak-atik pin dan mulai mencetak angka.
console.log({
scrollWidth: track.scrollWidth,
clientWidth: track.clientWidth,
distance: track.scrollWidth - track.clientWidth,
lastRight: track.lastElementChild.getBoundingClientRect().right,
});Di layar 1440px hasilnya:
scrollWidth: 1710
clientWidth: 1440
distance: 270
lastRight: 1710.4Tiga kartu 520px plus dua gap 32px sama dengan 1624px isi. Padding kiri 6vw sama dengan 86,4px. Jadi tepi kanan kartu terakhir berada di 1710,4px, dan itu cocok dengan lastRight. Tapi scrollWidth melaporkan 1710. Padding kanan yang lebarnya juga 86,4px tidak ikut terhitung di mana pun.
Dengan distance 270, tepi kanan kartu 03 berhenti di 1710,4 dikurangi 270 sama dengan 1440,4. Persis di garis potong, lewat nol koma sekian piksel. Itu penjelasan lengkap gejalanya.
Akar masalahnya: dua properti yang tidak sepakat soal padding
Dua properti yang saya kurangkan itu ternyata mengukur kotak yang berbeda.
clientWidth mengukur padding box. Dia menghitung lebar isi ditambah padding kiri ditambah padding kanan. Kedua padding masuk.
scrollWidth mengukur area overflow yang bisa di-scroll. Dia mulai dari tepi kiri padding box, jadi padding kiri ikut. Tapi begitu isinya meluber, Chromium dan WebKit berhenti di tepi kanan anak terakhir. Padding kanan container tidak dianggap bagian dari area itu.
Ini perilaku lama yang sama yang bikin orang bingung kenapa padding-right sebuah container overflow-x: auto seolah hilang begitu isinya di-scroll sampai mentok. Sama persis, cuma di sini akibatnya tidak kelihatan sebagai scrollbar yang aneh, melainkan sebagai animasi yang berhenti terlalu cepat.
Jadi selisih dua angka itu kurang sebesar padding-right, selalu, secara sistematis. Di 1440px kurangnya 86px. Di layar 1920px kurangnya jadi 115px karena paddingnya vw. Angka 86px yang saya kejar itu cuma angka di monitor saya sendiri. Di mesin lain kode yang sama memotong lebih tebal atau lebih tipis tanpa ada satu baris pun yang berubah.
Bonus kecil dari console.log tadi: scrollWidth mengembalikan bilangan bulat, sementara geometri aslinya 1710,4. Setengah piksel itu tidak akan pernah kamu lihat sendirian, tapi dia jadi bukti tambahan bahwa properti ini bukan alat ukur yang presisi untuk keperluan seperti ini.
Perbaikannya: ukur tepi kartu terakhir, bukan selisih dua properti
Godaan pertama tentu saja menambal saja: distance + paddingRight, selesai. Saya tidak mau, karena tambalan itu bergantung pada kuirk browser tetap berperilaku sama selamanya. Kalau suatu hari sebuah mesin mulai memasukkan padding kanan ke scrollWidth, tambalan itu berubah jadi kelebihan 86px, dan gejalanya jadi ruang kosong menganga di akhir pan.
Yang lebih tahan lama adalah berhenti bertanya "berapa lebar isinya" dan mulai bertanya "di mana tepi kanan kartu terakhir sekarang". Pertanyaan kedua dijawab getBoundingClientRect(), yang membaca geometri hasil render sungguhan, lengkap dengan pecahan piksel.
Satu masalah: getBoundingClientRect() sadar transform. Kalau kamu memanggilnya saat track sudah tergeser separuh jalan, yang kamu dapat posisi tergeser itu, bukan posisi asli. Dan pengukuran ulang justru paling sering terjadi di saat begitu, misalnya waktu pengunjung memutar ponsel atau menyeret jendela di tengah section.
Maka triknya: nolkan transform sebentar, ukur, kembalikan.
const viewport = document.querySelector('[data-hscroll]');
const track = viewport.querySelector('[data-hscroll-track]');
function measureDistance() {
const saved = track.style.transform;
// 1. kembalikan ke posisi natural
track.style.transform = 'translate3d(0, 0, 0)';
// 2. paksa layout ter-flush sebelum diukur
void track.offsetWidth;
// 3. baca geometri sungguhan
const last = track.lastElementChild;
const end = last.getBoundingClientRect().right;
const edge = viewport.getBoundingClientRect().right;
const padRight = parseFloat(getComputedStyle(track).paddingRight) || 0;
// 4. balikkan seperti semula
track.style.transform = saved;
return Math.max(0, end - edge + padRight);
}
let distance = measureDistance();Beberapa baris di situ kelihatan sepele padahal masing-masing menahan satu kegagalan.
Reset dan restore terjadi dalam satu tugas sinkron yang sama. Browser tidak pernah melukis di tengah-tengah keduanya, jadi tidak ada kedipan yang terlihat pengunjung. Track melompat ke nol dan balik lagi tanpa pernah sampai ke layar.
void track.offsetWidth memaksa flush. Membaca properti layout memaksa browser menerapkan perubahan gaya yang masih mengantre sebelum melanjutkan. Sebenarnya getBoundingClientRect() juga memaksa flush yang sama, jadi baris ini secara teknis mubazir. Saya tetap menaruhnya karena baris ini menyatakan niatnya secara eksplisit. Kode seperti ini gampang di-refactor: pengukurannya dipindah ke helper, hasilnya di-cache, urutannya bergeser sedikit. Begitu urutannya bergeser, yang kamu ukur adalah posisi tergeser, dan distance jadi meleset sejauh apa pun track sedang berada waktu itu. Itu bug yang jauh lebih menyakitkan dicari daripada satu baris yang harganya nyaris nol.
Tepi kanan diambil dari viewport, bukan dari window.innerWidth. Selama section ini full bleed dua angkanya sama. Begitu suatu hari section-nya dimasukkan ke container ber-max-width, innerWidth langsung salah dan getBoundingClientRect().right tetap benar.
Padding kanan dibaca dari computed style. Nilainya 6vw, jadi menulis angka mati di JavaScript sama saja memindahkan bug lama ke tempat baru. Biarkan CSS tetap jadi satu-satunya sumber kebenaran soal jarak.
Setelah itu tinggal memastikan angkanya tidak basi:
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
distance = measureDistance();
}, 200);
});
window.addEventListener('load', () => {
distance = measureDistance();
});Pengukuran diulang setelah load karena kartu berisi gambar. Sebelum gambarnya selesai dimuat, lebar kartu bisa belum final, dan pengukuran yang diambil terlalu dini akan menghasilkan jarak yang lagi-lagi kurang.
Debounce di resize juga bukan hiasan. Setiap panggilan measureDistance memaksa satu perhitungan layout sinkron, dan resize bisa menembak puluhan kali dalam satu tarikan jendela. Menunggu sampai penyeretan berhenti membuat pembacaan mahal itu terjadi sekali, bukan sekali per piksel.
Dengan ini kartu 03 berhenti di 1354px, punya jarak 86px ke tepi kanan, sama persis dengan jarak kartu 01 ke tepi kiri di awal pan. Simetri yang sejak awal dimaksudkan desainernya.
Epilog: perbaikan terbaiknya ternyata menghapus fiturnya
Beberapa hari kemudian klien melihat versi yang sudah benar, dan bilang bahwa scroll-lock ini sendiri terasa mengganggu di desktop. Scroll mereka seolah direbut, dan tiga kartu yang cuma berisi teks pendek tidak sepadan dengan rasa itu.
Section itu diganti grid tiga kolom biasa. Semua JavaScript-nya dihapus, termasuk fungsi pengukuran yang baru saja saya perbaiki.
Saya tidak menganggap itu jam yang terbuang. Dua hal ikut pulang. Pertama, mekanisme ukurnya sendiri masih saya pakai sampai sekarang di tempat lain yang benar-benar butuh geser horizontal. Kedua, pelajaran yang lebih pahit: sebelum menghabiskan waktu menyempurnakan sebuah interaksi, ada baiknya memastikan dulu interaksi itu memang diinginkan.
Yang saya bawa pulang
scrollWidth - clientWidthcuma benar untuk container yang di-scroll browser. Untuk track yang kamu geser sendiri pakaitransform, angkanya kurang sebesar padding kanan container.clientWidthmenghitung padding kiri dan kanan, sementarascrollWidthberhenti di tepi anak terakhir. Mengurangkan keduanya berarti mencampur dua kotak yang tidak sepakat.- Kalau kamu memanjangkan area scroll dan titik akhir animasi tidak bergeser sama sekali, berhenti mengutak-atik pin. Masalahnya ada di angka jarak, bukan di pemetaan progress.
getBoundingClientRect()sadar transform. Untuk mengukur posisi natural sebuah elemen yang sedang tergeser, nolkan transform-nya dulu, ukur, lalu kembalikan dalam tugas sinkron yang sama.- Baca nilai padding dari computed style, jangan tulis angka mati. Padding berbasis
vwmembuat setiap konstanta di JavaScript salah di lebar layar yang lain.