D
P
0
← Semua artikel Read in English

JavaScript, DOM & Animasi Browser

Marquee CSS Muter Tapi Ada Gap Kosong Gede Tiap Loop? `translateX(-50%)` Butuh Track Dua Kali Viewport

· · 6 menit baca
Marquee CSS Muter Tapi Ada Gap Kosong Gede Tiap Loop? `translateX(-50%)` Butuh Track Dua Kali Viewport

Marquee itu salah satu efek yang kelihatannya paling gampang di web. Satu keyframe, satu translateX, selesai. Sampai kamu buka hasilnya di monitor lebar dan sadar ada lubang kosong sebesar setengah layar yang muncul rutin tiap beberapa detik, seperti gerbong kereta yang hilang satu.

Saya kena persis itu waktu mengganti announcement bar di sebuah toko online. Sebelumnya bar itu berupa carousel yang berganti pesan dengan fade. Permintaannya sederhana: ganti jadi teks yang jalan terus tanpa putus.

Gejalanya: mulus di laptop, bolong di layar lebar

Animasinya jalan. Tidak ada error di console. Di layar laptop saya efeknya terlihat wajar, cuma agak renggang. Baru waktu dicek di monitor besar masalahnya telanjang: teks lewat, lalu ada jeda kosong panjang, lalu teks datang lagi dari kanan. Bukan marquee yang mengalir, tapi marquee yang kedip-kedip antara ada isi dan tidak ada isi.

Struktur awalnya persis seperti kebanyakan tutorial. Isi track digandakan di template, lalu track digeser setengah lebarnya sendiri:

{% for pass in (1..2) %}
  {% for message in section.blocks %}
    <span class="marquee__item">{{ message.settings.text }}</span>
  {% endfor %}
{% endfor %}
[data-marquee] { overflow: hidden; }
 
[data-marquee-track] {
  display: inline-flex;
  width: max-content;
  white-space: nowrap;
  animation: marquee var(--duration, 30s) linear infinite;
}
 
@keyframes marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

Logikanya benar. Kalau paruh pertama track identik dengan paruh kedua, menggeser sejauh 50 persen akan mendarat di posisi yang tampak sama dengan titik awal, dan loop-nya jadi tidak kelihatan sambungannya. Masalahnya bukan di situ.

Akar masalahnya: hitungan lebar, bukan animasi

Waktu itu toko cuma memasang satu pesan pendek, kira-kira sepanjang "Gratis ongkir". Setelah digandakan dua kali, total lebar track cuma sekitar 600 piksel. Viewport-nya 2000 piksel.

Jadi begini yang sebenarnya terjadi:

Syaratnya ternyata bukan "gandakan kontennya", tapi track harus paling tidak dua kali lebar viewport. Kalau tidak, tidak ada jumlah translateX yang bisa menutup layar, karena memang tidak ada isi yang cukup untuk ditutupkan.

Dan di sini letak jebakannya: duplikasi statis di template tidak pernah bisa menjamin syarat itu. Berapa kali harus digandakan tergantung dua hal yang baru diketahui saat runtime, yaitu berapa banyak pesan yang dipasang lewat admin dan seberapa lebar layar pengunjung. Satu pesan pendek di monitor 2560px butuh salinan jauh lebih banyak dibanding lima pesan panjang di ponsel. Angka (1..2) di template itu cuma tebakan yang kebetulan cocok di layar saya.

Ada jebakan kedua yang lebih halus. Waktu saya coba mengukur dan menambah salinan lewat JavaScript, hasilnya tetap kurang panjang di sebagian kunjungan. Penyebabnya pengukuran dilakukan sebelum web font selesai dimuat. Teks masih dirender dengan font fallback yang lebih sempit, jadi scrollWidth yang saya baca lebih kecil dari lebar sebenarnya. Begitu font asli masuk dan teks melar, track yang tadinya "sudah cukup" jadi kurang lagi.

Perbaikannya: klon sampai penuh, bukan klon sekali

Solusinya memindahkan keputusan jumlah salinan dari template ke runtime. JavaScript mengukur, lalu menggandakan isi track berulang kali sampai panjangnya melewati dua kali lebar viewport.

const viewport = document.querySelector('[data-marquee]');
const track = viewport.querySelector('[data-marquee-track]');
const speed = 60; // piksel per detik
 
// simpan susunan asli sekali di awal
const originals = Array.from(track.children).map((n) => n.cloneNode(true));
 
function apply() {
  // 1. kembalikan track ke isi aslinya dulu
  track.replaceChildren(...originals.map((n) => n.cloneNode(true)));
 
  // 2. gandakan seluruh isi sampai cukup panjang
  const target = viewport.offsetWidth * 2;
  let safety = 0;
  while (track.scrollWidth < target && safety < 12) {
    const snapshot = Array.from(track.children).map((n) => n.cloneNode(true));
    snapshot.forEach((n) => track.appendChild(n));
    safety++;
  }
 
  // 3. durasi ikut panjang track, bukan angka mati
  const duration = Math.max(8, (track.scrollWidth / 2) / speed);
  track.style.animationDuration = duration + 's';
}
 
document.fonts.ready.then(() => requestAnimationFrame(apply));

Ada empat keputusan di potongan kecil itu yang semuanya penting.

Reset dulu ke isi asli. Tanpa reset, jumlah salinan cuma bisa naik dan tidak pernah turun lagi. Pengunjung yang menyeret jendela dari monitor lebar ke jendela sempit meninggalkan track yang tetap sepanjang layar terlebar yang pernah diukur, dan semua salinan berlebih itu terus ikut dianimasikan. Menyimpan originals di awal membuat setiap pemanggilan berangkat dari kondisi yang sama.

Gandakan seluruh isi track, bukan tambah satu salinan. Ini bukan soal gaya. Menggandakan semua yang ada membuat isi track selalu terbagi jadi dua bagian yang identik, dan itu justru syarat supaya translateX(-50%) mendarat pas di sambungan. Kalau kamu menambah satu salinan per iterasi, gampang berakhir di jumlah salinan ganjil dan titik loop-nya meleset. Bonusnya, menggandakan itu tumbuh eksponensial, jadi konten sependek apa pun cukup beberapa iterasi untuk menutup layar terlebar.

Pasang batas pengaman. safety < 12 ada supaya loop tidak pernah jadi loop tak berujung. Kalau karena satu dan lain hal scrollWidth track tidak pernah bertambah, misalnya track-nya sendiri sedang display: none sementara pembungkusnya tetap punya lebar, tanpa batas itu tab-nya langsung mati.

Durasi dihitung dari kecepatan, bukan ditulis tetap. Ini bagian yang sering dilewatkan. Kalau kamu mengunci animation-duration: 30s, kecepatan visual marquee berubah-ubah mengikuti panjang isinya. Satu pesan pendek akan merayap, lima pesan panjang akan ngebut. Dengan menetapkan kecepatan dalam piksel per detik lalu membagi jarak tempuh, yaitu setengah lebar track, tempo yang dilihat pengunjung tetap sama apa pun isinya. Math.max(8, ...) menahan agar loop tidak jadi terlalu pendek dan terasa gelisah.

Terakhir, semuanya dijalankan setelah document.fonts.ready supaya pengukuran memakai lebar teks yang sebenarnya, dan dibungkus requestAnimationFrame agar bacaan layout diambil setelah browser selesai menata ulang.

Jangan lupa resize

Perhitungannya bergantung pada viewport.offsetWidth, jadi begitu jendela berubah lebar, angkanya basi. Orang memutar ponsel, orang menyeret jendela ke monitor kedua, dan gap kosongnya kembali.

let resizeTimer;
window.addEventListener('resize', () => {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(apply, 200);
});

Debounce-nya wajib. apply membaca scrollWidth dan menulis ke DOM, jadi memanggilnya di tiap event resize berarti memaksa browser menghitung ulang layout puluhan kali per detik sambil kamu menyeret jendela.

Yang saya bawa pulang