Ada satu jenis kegagalan yang tidak muncul di console dan tidak bikin build merah. Halamannya jalan, animasinya jalan, tidak ada satu pun error. Tapi begitu kamu scroll dari satu section ke section berikutnya, matamu langsung tahu ada yang salah. Transisinya kelihatan seperti lapisan hitam transparan yang ditempel asal, dengan garis-garis tangga samar di tengah gradasi. Bukan bug teknis, tapi tetap bug.
Saya kena ini waktu mengerjakan sebuah situs company profile dengan scroll immersive. Konsepnya: pemandangan hero perlahan larut ke section berikutnya, tanpa batas yang terasa. Saya sudah punya rujukan, satu halaman yang transisinya persis seperti itu dan mulus total. Saya tiru pendekatannya. Hasil saya banding, hasil rujukannya tidak.
Gejalanya: dua hal yang saya kira satu masalah
Kalau kamu perbesar screenshot transisi saya, ada dua cacat yang sebenarnya berdiri sendiri:
Pertama, banding. Gradasi dari terang ke gelap tidak halus. Ada pita-pita horizontal yang kelihatan seperti anak tangga, paling parah di area tengah, dan makin jelas di layar dengan kontras tinggi.
Kedua, dan ini yang lebih merusak, transisinya terbaca sebagai lapisan. Otak orang yang melihatnya tidak berpikir "wah, langitnya menggelap". Otaknya berpikir "ada kotak hitam transparan di atas fotonya". Persis seperti scrim di panggung teater yang lupa dimatikan lampunya.
Saya sempat lama menganggap keduanya satu penyakit dan mencari satu obat. Salah. Yang pertama soal jumlah stop, yang kedua soal susunan layer, dan ada penyakit ketiga yang bahkan belum saya sadari.
Audit baris demi baris terhadap versi yang jalan
Karena tebak-tebakan tidak jalan, saya berhenti mengutak-atik dan membuka CSS halaman rujukan itu, dibaca baris demi baris seperti membaca diff. Butuh sekitar dua puluh menit, dan ada tiga perbedaan yang muncul.
Satu: scrim saya duduk DI ATAS foto, punya mereka jadi latar itu sendiri. Punya saya adalah linear-gradient semi transparan yang ditumpuk di atas gambar. Punya mereka adalah blok yang latarnya memang gradient opaque, dan fotonya masuk sebagai tekstur di dalamnya. Beda susunan, beda hasil. Yang satu terlihat seperti filter, yang satu terlihat seperti pemandangan.
Dua: saya cuma pakai tiga atau empat stop, mereka pakai enam belas. Dan bukan enam belas stop yang jaraknya rata secara nilai alpha, tapi enam belas stop di kurva alpha yang di-ease.
Tiga: mereka tidak pernah menyambung foto ke foto. Ini yang paling penting dan paling tidak saya duga. Blok pertama memudar ke satu warna solid yang eksak. Blok berikutnya dibuka dari warna solid yang sama persis. Jadi yang bertemu di sambungan itu bukan dua gambar, tapi dua bidang warna identik. Mustahil kelihatan seam kalau dua sisi sambungannya warna yang sama.
Kenapa empat stop linear pasti banding
Waktu kamu tulis gradient dua stop, dari transparan ke solid, browser yang menginterpolasi di antaranya. Interpolasi itu linear di ruang warna, dan mata manusia tidak linear. Di area alpha rendah, perubahan kecil terasa besar. Di area alpha tinggi, perubahan besar hampir tidak terasa. Hasilnya, distribusi perubahan visual jadi tidak rata dan lompatan antar level terkuantisasi jadi kelihatan sebagai pita.
Solusinya bukan menambah stop asal banyak. Solusinya adalah menaruh stop lebih rapat di awal, di area yang matamu paling sensitif, lalu melebar ke belakang. Ini kurva alpha yang saya salin dari halaman rujukan:
0, .01, .03, .07, .12, .18, .25, .33, .41, .50, .59, .67, .76, .85, .93, 1Perhatikan jarak antar nilai di awal: 0.01, lalu 0.02, lalu 0.04. Sangat pelan. Baru di tengah jaraknya melebar ke 0.08 dan 0.09. Kurva ini yang bikin awal fade terasa hampir tidak terjadi, lalu menggelap dengan percepatan yang terasa alami.
Dituangkan ke CSS, dengan enam belas stop yang tersebar rata di ketinggian 75vh:
.hero-grad-over {
position: absolute;
left: 0;
right: 0;
bottom: -1px; /* bukan 0 */
height: 75vh;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(1, 90, 169, 0) 0%,
rgba(1, 90, 169, 0.01) 6.66%,
rgba(1, 90, 169, 0.03) 13.33%,
rgba(1, 90, 169, 0.07) 20%,
rgba(1, 90, 169, 0.12) 26.66%,
rgba(1, 90, 169, 0.18) 33.33%,
rgba(1, 90, 169, 0.25) 40%,
rgba(1, 90, 169, 0.33) 46.66%,
rgba(1, 90, 169, 0.41) 53.33%,
rgba(1, 90, 169, 0.5) 60%,
rgba(1, 90, 169, 0.59) 66.66%,
rgba(1, 90, 169, 0.67) 73.33%,
rgba(1, 90, 169, 0.76) 80%,
rgba(1, 90, 169, 0.85) 86.66%,
rgba(1, 90, 169, 0.93) 93.33%,
rgba(1, 90, 169, 1) 100%
);
}Dua detail yang gampang dilewat di blok ini.
Yang pertama, stop terakhir solid, bukan 0.95 atau 0.98. Kalau kamu berhenti di 0.98, akan selalu ada sedikit foto yang menembus, dan itu cukup untuk merusak checkpoint di bagian berikutnya.
Yang kedua, bottom: -1px, bukan bottom: 0. Di layar dengan device pixel ratio pecahan, tinggi elemen yang dihitung browser bisa mendarat di setengah piksel, dan hasilnya garis rambut terang di batas bawah. Menggeser satu piksel ke bawah membunuh celah subpixel itu tanpa efek samping, karena satu piksel terbawahnya sudah warna solid.
Scrim yang duduk di atas foto tidak akan pernah menyatu
Perbaikan stop menghilangkan banding, tapi transisinya masih terbaca sebagai tempelan. Ini masalah susunan layer, dan perbaikannya adalah membalik cara berpikir.
Yang saya lakukan sebelumnya: foto sebagai latar, gradient sebagai overlay di atasnya.
Yang benar: gradient adalah latarnya, foto cuma tekstur di dalam latar itu. Blok berikutnya dibuka dengan gradient yang sepenuhnya opaque, dan foto langit di dalamnya diturunkan opasitasnya sampai dia berhenti jadi subjek dan berubah jadi tekstur.
.about-block {
position: relative;
background: linear-gradient(
to bottom,
#015aa9 0%,
#8fc2d7 50%,
#2b2b2b 100%
);
}
.about-block__texture {
position: absolute;
inset: 0;
opacity: 0.75;
object-fit: fill; /* bukan cover */
}object-fit: fill di sini terlihat salah kalau kamu terbiasa dengan aturan umum bahwa cover adalah pilihan aman. Tapi begitu foto itu turun status jadi tekstur, distorsi rasio aspeknya justru tidak masalah, dan yang jauh lebih penting adalah gradient di baliknya tidak boleh bocor di sisi mana pun. cover akan memotong, contain akan menyisakan ruang, fill menjamin bidangnya tertutup penuh sehingga warna yang kamu lihat di atas dan di bawah blok itu benar-benar warna yang kamu tulis.
Hasilnya, warna di titik teratas blok ini adalah #015aa9 yang sudah dilunakkan tekstur, bukan foto yang kebetulan kebiruan.
Bagian yang paling saya remehkan: checkpoint warna eksak
Sampai titik ini, dua blok saya sudah cantik masing-masing tapi sambungannya masih meleset. Karena masih ada satu asumsi salah yang saya bawa: saya kira tugasnya adalah menyambungkan blok satu ke blok dua.
Bukan. Tugasnya adalah memaksa keduanya bertemu di warna yang sama.
Blok pertama berakhir solid #015aa9. Blok kedua dimulai dari #015aa9. Sambungannya jadi tidak bisa gagal, karena secara visual tidak ada yang disambung. Warna solid ketemu warna solid identik. Mau layarnya kalibrasi apa, mau browsernya bulatkan tinggi elemen ke mana, hasilnya tetap satu bidang warna.
Ini juga yang bikin pendekatan foto-ke-foto secara fundamental rapuh. Dua foto tidak akan pernah cocok sempurna di batasnya kecuali memang satu gambar yang dipotong. Warna solid selalu cocok.
Geometrinya juga harus checkpoint
Prinsip yang sama berlaku ke angka scroll, dan ini yang menyelamatkan saya waktu blok hero-nya masih bergerak.
Blok hero saya setinggi 200vh, dan di dalamnya ada layer yang drift dari y: 0 ke y: 100vh sepanjang blok itu. Artinya, ujung perjalanan drift itu mendarat di posisi scroll 300vh. Dan 300vh harus persis titik di mana warna solid blok berikutnya dimulai.
gsap.to(".hero-layer", {
y: () => window.innerHeight, // 0 -> 100vh
ease: "none",
scrollTrigger: {
trigger: ".hero-block", // tinggi 200vh
start: "top top",
end: "bottom top",
scrub: true,
},
});Aritmatikanya sederhana tapi wajib ditulis dan dicek: tinggi blok ditambah jarak drift harus sama dengan posisi awal warna solid berikutnya. Kalau kamu ubah salah satunya nanti, misalnya blok hero dinaikkan jadi 240vh karena copy-nya bertambah, sambungannya langsung meleset lagi dan gejalanya akan terlihat persis seperti masalah gradient, padahal gradient-mu baik-baik saja. Saya taruh komentar di atas kedua angka itu supaya versi saya berikutnya tidak mengulang.
Kalau butuh seam yang benar-benar piksel
Untuk situs itu ada satu bagian yang tidak bisa diselesaikan dengan trik warna solid, karena memang harus terlihat sebagai satu pemandangan panjang yang tidak terputus sepanjang scroll.
Solusinya bukan CSS. Solusinya adalah berhenti memakai dua gambar. Saya panggang satu panorama vertikal utuh di luar browser lewat ffmpeg, dengan alpha ramp supaya bagian bawah gambar atas larut ke gambar di bawahnya sebelum keduanya digabung:
ffmpeg -i atas.png -vf \
"format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*(1-Y/H)'" \
atas-ramp.pngSetelah dua gambar itu jadi satu file, tidak ada lagi sambungan yang bisa meleset, karena sambungannya sudah selesai di tahap render, bukan di runtime.
Layer panorama itu saya beri tinggi max(107vw, 135svh) supaya dia selalu lebih tinggi dari viewport di rasio mana pun, lalu di-pan sepanjang kedatangan section:
const layerHeight = layer.offsetHeight;
const vh = window.innerHeight;
gsap.fromTo(
layer,
{ yPercent: 102 },
{
yPercent: -(1 - vh / layerHeight) * 100,
ease: "none",
scrollTrigger: {
trigger: stage,
start: "top bottom",
end: () => stage.offsetHeight - vh,
scrub: true,
},
}
);Angka akhirnya bukan -100, dan itu bukan selera. yPercent: -100 akan menggeser layer sampai seluruh tingginya keluar dari titik awal, yang berarti bagian bawah panorama ikut lewat dan kamu kehilangan satu viewport terakhir gambar. Rumus -(1 - vh / layerHeight) * 100 berhenti tepat ketika sisi bawah panorama sejajar dengan sisi bawah viewport. Persis satu layar penuh tersisa terlihat, tidak lebih, tidak kurang.
Yang saya bawa pulang
- Kalau punyamu banding dan punya orang lain tidak, jangan tebak. Buka CSS-nya dan baca baris demi baris. Dua puluh menit membaca menyelesaikan apa yang tidak selesai dalam berjam-jam mengutak-atik nilai.
- Banding itu soal distribusi stop, bukan jumlah warna. Enam belas stop di kurva yang di-ease jauh lebih halus daripada empat stop linear, karena mata manusia paling sensitif di ujung alpha yang rendah.
- Scrim transparan di atas foto akan selalu terbaca sebagai lapisan. Balik susunannya: gradient jadi latar, foto turun jadi tekstur.
- Jangan pernah menyambung foto ke foto. Fade ke satu warna solid eksak, lalu buka blok berikutnya dari warna solid yang sama. Sambungan yang tidak bisa gagal adalah sambungan yang secara visual tidak ada.
- Geometri scroll juga butuh checkpoint. Tulis aritmatikanya sebagai komentar, karena satu perubahan tinggi section bisa merusak sambungan yang gradient-nya sempurna.
bottom: -1pxbukan hack sembarangan. Itu obat spesifik untuk celah subpixel di device pixel ratio pecahan.