D
P
0
← Semua artikel Read in English

Next.js & React di Produksi

Transisi Antar Section Jadi Double-Image Berlumpur? Dissolve Lewat Satu Warna Dasar, Jangan Tumpuk Dua Scene

· · 5 menit baca
Transisi Antar Section Jadi Double-Image Berlumpur? Dissolve Lewat Satu Warna Dasar, Jangan Tumpuk Dua Scene

Bug ini saya yang menangkap sendiri, bukan datang dari laporan orang lain. Di sambungan antara dua section yang menempel, dua scene sibuk tampil bersamaan dan hasilnya double-image berlumpur.

Yang bikin sambungan seperti ini tidak enak diselesaikan, dua pilihan gampangnya sama-sama jelek. Overlap-nya dibiarkan, hasilnya double-image tadi. Overlap-nya dibuang, section berikutnya jadi kelihatan slide masuk dan efeknya murahan.

Halamannya sendiri satu scroll panjang berisi lima panggung berurutan: hero, panggung narasi, panggung centerpiece, galeri, lalu penutup. Panggung yang isinya beranimasi menempel di layar lewat sticky top-0 h-svh, bergerak mengikuti progres scroll, lalu melepas dan menyerahkan layar ke panggung berikutnya. Sambungan antar panggung itulah yang bermasalah.

Overlap-nya bukan kecelakaan

Kalau dua scene terlihat bersamaan, berarti pada momen serah terima keduanya memang sama-sama ada di dalam viewport, dan tidak ada disiplin fade lewat satu warna yang memisahkan mereka. Tidak ada yang melarang. Section yang keluar belum selesai keluar, section yang masuk sudah sampai, dan keduanya masih tampil penuh.

Pertanyaan yang lebih penting: kenapa mereka sampai berbagi ruang begitu?

Karena memang saya yang menyuruh. Section yang masuk ditarik naik marginTop: -100svh supaya panggung pinned-nya menumpuk viewport terakhir section sebelumnya, jadi keduanya berbagi kotak yang sama. Tarikan itu yang membuat section berikutnya selalu pin tepat saat progres section sekarang mencapai 1.0.

Jadi overlap itu mekanismenya, bukan cacatnya. Yang salah bukan keberadaan overlap, tapi keputusan membiarkan dua scene sama-sama terlihat penuh selama overlap berlangsung.

Aturannya: dissolve di tempat, tidak pernah slide

Setelah beberapa kali iterasi, aturannya berhenti di satu kalimat. Setiap seam adalah dissolve di tempat lewat navy, tidak pernah slide.

Praktiknya begini. Tiap section fade in dari navy tepat pada saat dia pin, lalu fade out balik ke navy di ujung rentangnya, supaya section setelahnya juga muncul dari navy dan bukan dari sisa gambar sebelumnya. Karena opacity-nya nol selama dia naik, gerakan naiknya tidak pernah terlihat oleh siapa pun.

Navy itu bukan warna baru yang saya karang untuk transisi. Itu warna panggung yang memang sudah dipakai halaman ini.

Satu wrapper untuk semua sambungan

Semuanya saya bungkus jadi satu wrapper generik supaya tiap sambungan berperilaku identik. Wrapper ini akhirnya tercatat sebagai salah satu shared primitive yang lahir dari build itu.

'use client';
 
import { useRef } from 'react';
import { motion, useScroll, useTransform } from 'motion/react';
 
type SeamDissolveProps = {
  z?: number;
  fadeOut?: boolean;
  children: React.ReactNode;
};
 
export default function SeamDissolve({ z = 10, fadeOut = true, children }: SeamDissolveProps) {
  const ref = useRef<HTMLDivElement>(null);
 
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ['start start', 'end end'],
  });
 
  const opacity = useTransform(
    scrollYProgress,
    fadeOut ? [0, 0.06, 0.94, 0.995] : [0, 0.06],
    fadeOut ? [0, 1, 1, 0] : [0, 1]
  );
 
  return (
    <motion.div
      ref={ref}
      style={{ opacity, marginTop: '-100svh', position: 'relative', zIndex: z }}
    >
      {children}
    </motion.div>
  );
}

Offset ['start start', 'end end'] yang membuat angka-angka itu bisa dibaca: progres 0 adalah momen pin, dan progres 1 adalah momen pin section berikutnya. Jadi enam persen pertama dipakai untuk muncul, larutnya mulai di 0.94, dan selesai di 0.995, sedikit sebelum rentangnya benar-benar habis.

marginTop: -100svh yang menarik section ini naik supaya panggungnya berbagi kotak dengan viewport terakhir section sebelumnya. Style wrapper-nya cuma empat properti: opacity, marginTop, position relative, dan zIndex dari prop z.

Prop z naik per section, dan itu yang menentukan siapa menutupi siapa selama masa overlap. Defaultnya 10.

Kalau fadeOut dimatikan, keyframe-nya tinggal bagian muncul saja, [0, 0.06] ke [0, 1]. Itu yang dipakai section terakhir supaya dia tetap tinggal.

Yang dibungkus dan yang tidak

Tidak semua panggung lewat wrapper ini.

Hero tidak butuh fade in karena dia yang pertama, tidak ada apa pun sebelum dia. Yang dia punya cuma fade out sendiri di ujungnya: opacity panggungnya 1 ke 0 sekaligus scale 1 ke 1.06, keduanya di rentang [0.84, 0.985].

Panggung kedua juga tidak dibungkus. Di berkas halaman dia cuma div biasa dengan class relative z-10 dan marginTop: '-100svh' inline, sementara ramp masuknya, [0, 0.06] ke [0, 1], tinggal di dalam komponennya sendiri karena dia masih memegang fade akhir dan serah terimanya sendiri ke galeri. Latarnya juga dipindah ke elemen sticky di dalamnya dan elemen section-nya dibuat transparan. Angka 10 di panggung ini class Tailwind, bukan nilai prop, dan itu beda yang gampang tertukar waktu membaca catatan lama.

Tiga panggung sisanya yang benar-benar memakai wrapper-nya langsung di berkas halaman, dengan z 15, 20, dan 30.

<HeroStage />
 
<div className="relative z-10" style={{ marginTop: '-100svh' }}>
  <NarrativeStage />
</div>
 
<SeamDissolve z={15}><CenterpieceStage /></SeamDissolve>
<SeamDissolve z={20}><GalleryStage /></SeamDissolve>
<SeamDissolve z={30} fadeOut={false}><ClosingStage /></SeamDissolve>

Pertanyaan yang paling saya khawatirkan

Kekhawatiran terbesar saya bukan opacity-nya, tapi apa yang terjadi pada anak-anaknya. Wrapper ini menaruh opacity di elemen yang di dalamnya ada position: sticky, dan yang saya takutkan opacity berperilaku seperti transform dan filter yang mengubah containing block anak-anaknya. Kalau sticky-nya rusak, seluruh mekanik halaman ikut rusak.

Hasilnya aman, dan ini sudah diverifikasi, bukan diasumsikan: opacity di wrapper tidak merusak pinning position: sticky anaknya. Anak yang di-pin itu memang sticky top-0 h-svh di dalam wrapper ber-opacity tadi, dan dia tetap menempel seperti sebelumnya. Opacity di bawah satu memang membentuk stacking context, tapi dia tidak membentuk containing block baru, jadi sticky masih menghitung posisinya terhadap kotak scroll yang sama.

Cara memeriksanya, dan siapa yang menutup kasusnya

Screenshot halaman ini tidak bisa dipakai. Dia macet, karena rAF dari smooth scroll dan motion tidak pernah idle. Jadi verifikasi scroll-nya saya lakukan lewat browser_evaluate: mengukur posisi atas tiap section beserta overlap-nya, lalu scrollTo ke titik tertentu dan memastikan rect.top elemen sticky-nya mendekati nol.

Yang tidak bisa dilaporkan alat mana pun adalah rasa dari seam-nya sendiri. Itu tetap butuh mata saya, sama seperti waktu bug double-image tadi ketahuan.

Catatan penutup

Satu hal yang perlu saya tulis jujur di sini. Build immersif tempat wrapper ini hidup bukan versi yang tayang. Klien menyetujui build standar sebagai deliverable, dan itu yang jadi versi live, sementara yang immersif pindah ke branch terpisah.