D
P
0
← Semua artikel Read in English

JavaScript, DOM & Animasi Browser

Kartu Pinned ScrollTrigger Nutupin Section CTA? Salahnya di `end: "bottom top"`

· · 6 menit baca
Kartu Pinned ScrollTrigger Nutupin Section CTA? Salahnya di `end: "bottom top"`

Bug ini tidak melempar apa pun ke console. Tidak ada warning GSAP, tidak ada layout yang jebol, tidak ada angka merah di Lighthouse. Yang ada cuma satu hal yang kelihatan salah dengan mata telanjang: section CTA di bagian bawah halaman ketutupan tumpukan kartu yang harusnya sudah lepas beberapa layar sebelumnya.

Saya menemukannya waktu audit pra-handoff sebuah tema WordPress premium. Halaman utamanya punya section "keunggulan" dengan gaya stacking card yang lagi banyak dipakai: kartu-kartu ditumpuk, tiap kartu di-pin di posisi sedikit lebih rendah dari kartu sebelumnya, jadi waktu kamu scroll, kartunya menumpuk rapi seperti kartu remi yang dibuka setengah. Enam kartu, animasinya mulus, transisinya enak. Sampai kamu scroll sedikit lebih jauh.

Gejalanya: kartu terakhir tidak pernah pamit

Begitu section kartu selesai, harusnya CTA masuk bersih dari bawah. Yang terjadi malah kartu nomor enam ikut jalan terus ke bawah, nempel di layar, dan duduk di atas CTA. Tombolnya kehalang. Sebagian headline CTA kelihatan mengintip dari balik kartu. Kalau kamu terus scroll, akhirnya kartunya lepas juga, tapi sudah telat: momen paling penting di halaman itu ketutupan persis waktu pengunjung mestinya mengkliknya.

Refleks pertama saya salah, dan saya curiga refleks kamu juga akan sama: saya langsung mikir ini urusan z-index. Kartunya di atas, CTA di bawah, tinggal naikkan CTA atau turunkan kartunya. Saya sempat main-main dengan stacking context sebentar sebelum sadar bahwa itu cuma menyembunyikan gejala. Kartunya memang seharusnya ada di atas segalanya selama dia masih di-pin. Masalahnya bukan siapa yang di atas siapa. Masalahnya kartunya masih di-pin waktu seharusnya sudah tidak.

Konfigurasinya waktu itu kira-kira begini:

const cards = gsap.utils.toArray(".stack__card");
 
cards.forEach((card, i) => {
  ScrollTrigger.create({
    trigger: card,
    start: () => `top ${40 + 70 * i}`,
    end: "bottom top", // ini biang keroknya
    pin: true,
    pinSpacing: false,
  });
});

Yang bikin saya lama menyadarinya: bagian start di situ benar dan hasilnya memang bagus. Offset 40 + 70 * i yang bikin tiap kartu berhenti 70px lebih rendah dari kartu sebelumnya, dan itu persis efek tumpukan yang diminta. Karena bagian yang kelihatan "pintar" jalan sempurna, saya tidak curiga ke baris di sebelahnya.

Akar masalahnya: bottom top itu jauh lebih panjang dari yang kamu kira

Nilai start dan end di ScrollTrigger selalu dibaca dengan pola yang sama: kata pertama adalah titik di elemennya, kata kedua adalah titik di viewport. Dua kata itu ketemu, di situlah trigger mulai atau berhenti.

Jadi end: "bottom top" artinya: pin baru dilepas ketika sisi bawah kartu menyentuh sisi atas viewport. Bayangkan pelan-pelan. Kartunya memang menempel di layar selama di-pin, tapi titik lepasnya tidak dihitung dari posisi tempelan itu. ScrollTrigger menghitungnya dari posisi asli kartu di aliran halaman, dan posisi asli itu terus naik selama kamu scroll. Selama sisi bawah versi asli itu belum lewat atap viewport, kartunya masih dianggap aktif dan masih menempel.

Bandingkan dengan end: "bottom bottom": pin dilepas saat sisi bawah yang sama baru menyentuh sisi bawah viewport, alias tepat saat elemennya selesai masuk layar. Sisi bawah yang sama itu harus menyeberangi seluruh layar untuk sampai dari titik pertama ke titik kedua, jadi selisih dua nilai itu bukan beberapa piksel: selisihnya persis satu tinggi viewport. Di layar desktop setinggi 900px, itu 900 piksel scroll ekstra di mana kartu masih menempel.

Dan sembilan ratus piksel itu persis wilayah tempat section CTA hidup.

Kenapa pinSpacing: false bikin gejalanya jadi tumpang tindih, bukan celah kosong

Ini bagian yang menjelaskan kenapa bugnya muncul sebagai tumpang tindih dan bukan sebagai jarak kosong raksasa.

Secara default, pin: true menyisipkan pin-spacer: sebuah elemen kosong setinggi durasi pin, supaya konten di bawahnya tetap tergeser dan tidak ada yang ketiban. Kalau spacer itu aktif, end yang kepanjangan hasilnya adalah ruang kosong yang aneh, jelek tapi tidak menutupi apa pun.

Efek stacking card justru butuh pinSpacing: false, karena kartu-kartunya memang harus saling tindih tanpa mendorong halaman jadi panjang berlipat. Konsekuensinya: tidak ada ruang yang dipesan sama sekali. Konten berikutnya, dalam hal ini CTA, tetap naik seperti biasa dan lewat di bawah kartu yang masih menempel.

Jadi dua keputusan yang masing-masing masuk akal bertemu jadi satu bug: pinSpacing: false yang memang wajib untuk efeknya, ditambah end yang kelamaan. Yang satu menghapus jaring pengaman, yang satu membuat pin hidup terlalu lama.

Perbaikannya: ukur ujungnya dari pembungkusnya, bukan dari kartunya

Perbaikan yang benar bukan cuma ganti bottom top jadi bottom bottom. Kalau kamu cuma menukar itu, tiap kartu akan lepas berdasarkan sisi bawah dirinya sendiri, dan tumpukannya bubar satu per satu dari bawah. Yang kita mau justru sebaliknya: semua kartu bertahan sebagai tumpukan sampai section kartunya benar-benar habis, lalu lepas bareng.

Untuk itu ada endTrigger. Properti ini memisahkan "elemen yang memulai" dari "elemen yang mengakhiri", jadi end diukur terhadap pembungkus kartunya:

const cardsWrap = document.querySelector(".stack__cards");
const cards = gsap.utils.toArray(".stack__card");
 
cards.forEach((card, i) => {
  ScrollTrigger.create({
    trigger: card,
    start: () => `top ${40 + 70 * i}`,
    endTrigger: cardsWrap,
    end: "bottom bottom",
    pin: true,
    pinSpacing: false,
  });
});

Sekarang tiap kartu tetap mulai di posisinya sendiri, tapi semuanya berhenti di titik yang sama: saat sisi bawah pembungkus kartu menyentuh sisi bawah viewport. Artinya kartu terakhir lepas sebelum CTA masuk, bukan sesudahnya.

Karena efek tumpukannya sendiri cuma masuk akal di layar lebar, dan di mobile kartunya memang dibiarkan mengalir biasa satu per satu, semua ini dibungkus supaya tidak pernah jalan di layar kecil:

const mm = gsap.matchMedia();
 
mm.add("(min-width: 768px)", () => {
  const cardsWrap = document.querySelector(".stack__cards");
  const cards = gsap.utils.toArray(".stack__card");
 
  cards.forEach((card, i) => {
    ScrollTrigger.create({
      trigger: card,
      start: () => `top ${40 + 70 * i}`,
      endTrigger: cardsWrap,
      end: "bottom bottom",
      pin: true,
      pinSpacing: false,
    });
  });
});

gsap.matchMedia() enak dipakai di sini karena dia otomatis membersihkan semua trigger yang dibuat di dalamnya begitu kondisinya tidak terpenuhi lagi. Waktu pengunjung memutar tablet dari landscape ke portrait, kamu tidak meninggalkan pin yatim yang masih memegang posisi elemen.

Cara memastikannya benar-benar beres

Bug posisi seperti ini gampang "kelihatan beres" padahal cuma bergeser sedikit, jadi saya tidak mengandalkan perasaan. Saya ambil screenshot pada beberapa posisi scroll di sekitar transisi kartu ke CTA, lalu cek satu hal yang tidak bisa berbohong: apakah latar gelap CTA dan teks putihnya terlihat utuh tanpa ada potongan kartu yang mengintip di atasnya. Kalau di semua frame CTA-nya bersih, pin-nya benar-benar sudah lepas di tempat yang benar.

Kalau kamu mau cek lebih cepat tanpa screenshot, markers: true di salah satu trigger juga langsung menjawabnya. Marker end yang jatuh jauh di bawah awal section berikutnya adalah gambar dari bug ini.

Yang saya bawa pulang