D
P
0

CSS & Web Animation

Teks Reveal Stuck di `opacity: 0.25`? Engine i18n Re-apply `innerHTML` dan Men-detach Node Animasi

27 Juli 2026·4 menit baca
Teks Reveal Stuck di `opacity: 0.25`? Engine i18n Re-apply `innerHTML` dan Men-detach Node Animasi

Saya lagi ngerjain layer motion di custom theme WordPress buat situs klien yang dwibahasa, Prancis dan Inggris. Ada satu efek reveal yang saya sebut read-wipe di paragraf-paragraf lead (.section-lead, .story-text p, .visit-lead): tiap kata mulai dari pudar lalu naik ke penuh begitu masuk viewport. Di kebanyakan blok, jalan mulus. Tapi di beberapa lead, teksnya nyangkut. Bukan hilang, bukan blank, tapi mentok di opacity: 0.25 selamanya. Pudar permanen, padahal animasinya jalan dan console bersih tanpa satu pun error.

Yang bikin pusing: nggak ada yang kelihatan salah. GSAP jalan, IntersectionObserver fire, tween-nya complete. Kalau saya inspect elemennya, inline style-nya beneran opacity: 0.25. Seolah animasi selesai men-tween ke 1, tapi elemen di layar nggak ikut naik.

Gejalanya

Saya mulai dari yang paling gampang. Saya scroll bolak-balik buat mastiin IntersectionObserver-nya kepicu. Kepicu. Saya taruh log di callback tween, onComplete-nya jalan. Saya cek target selector, ke-resolve ke node yang benar waktu animasi dibangun. Semua indikator bilang sukses, tapi hasilnya tetap pudar.

Dead end pertama: saya kira ini masalah CSS specificity, ada aturan lain yang override opacity balik ke 0.25. Saya sisir stylesheet, nggak ada. Dead end kedua: saya kira ada timeline lain yang nge-kill tween-nya di tengah jalan. Saya isolasi tween-nya, tetap pudar. Dead end ketiga: saya sempat nyalahin SplitText dan ganti konfigurasi split-nya, masih sama.

Yang akhirnya ngebuka teka-teki: saya bandingin node yang saya tween sama node yang beneran ada di DOM sekarang. Node target animasinya ternyata udah nggak nyambung ke dokumen. Node yang di layar itu node lain.

Kenapa ini terjadi

Kunci yang sebelumnya saya lewatin: semua lead yang bermasalah itu punya atribut data-i18n. Situs ini dwibahasa, dan ada engine i18n custom yang jalan di sisi client. Cara kerjanya sederhana: dia nge-cache innerHTML tiap elemen ber-data-i18n, lalu nge-set ulang innerHTML itu pas nerjemahin bahasa.

Race-nya begini. Splitter reveal saya jalan lebih dulu. Dia mecah teks lead jadi banyak span per kata, dan nge-set inline opacity: 0.25 di tiap span. Baru setelah itu engine i18n jalan. Waktu i18n nge-cache innerHTML, yang dia tangkap udah versi ter-split, lengkap dengan opacity: 0.25 yang nempel. Lalu i18n nge-set ulang innerHTML itu ke elemen. Begitu innerHTML di-tulis ulang, browser mem-parse ulang string-nya jadi node-node baru. Node lama, yang jadi target tween GSAP, ter-detach dari DOM.

Jadi animasinya beneran jalan, tapi dia nge-tween node hantu yang udah lepas dari dokumen. Node yang live di layar itu hasil re-parse, dan mereka lahir dengan opacity: 0.25 yang ke-cache tadi, tanpa ada yang nge-tween mereka ke 1. Makanya pudar permanen.

Ada satu lapisan bonus. SplitText sendiri juga suka nge-split ulang pas font selesai loading, buat ngukur ulang posisi baris. Re-split itu juga nulis ulang DOM dan men-detach node dengan pola yang persis sama. Jadi ada dua sumber detach: i18n dan re-split pas font load.

Intinya: elemen yang punya data-i18n DAN reveal yang nulis ulang DOM pasti berantem, kecuali reveal-nya dijalanin setelah i18n kelar.

Perbaikannya

Ada dua bagian.

Pertama, urutan. i18n harus apply duluan, reveal belakangan. i18n saya biarin jalan di DOMContentLoaded, dan buildSplitReveals baru saya panggil setelah window.load plus document.fonts.ready. Dengan begitu, waktu splitter mecah teks, i18n udah nggak bakal nulis ulang innerHTML lagi, dan font udah settle jadi SplitText nggak re-split.

document.addEventListener('DOMContentLoaded', applyI18n);
 
window.addEventListener('load', () => {
  document.fonts.ready.then(() => {
    buildSplitReveals();
  });
});

Kedua, buat lead saya berhenti pakai SplitText dan ganti ke splitter hand-rolled yang stabil, dengan kelas .rwi. Dia mecah sekali, nggak pernah re-split sendiri. Reveal-nya pakai IntersectionObserver polos yang, pas elemen masuk viewport, nge-query ulang node .rwi yang live saat itu juga lalu nge-tween mereka:

function splitLead(el) {
  const words = el.textContent.trim().split(/\s+/);
  el.innerHTML = words.map((w) => `<span class="rwi">${w}</span>`).join(' ');
}
 
const io = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return;
    gsap.to(entry.target.querySelectorAll('.rwi'), { opacity: 1, stagger: 0.02 });
    io.unobserve(entry.target);
  });
});

Poin pentingnya ada di baris entry.target.querySelectorAll('.rwi'): node-nya di-query ulang tepat pas animasi mau jalan, bukan disimpan dari jauh-jauh hari. Jadi walau ada yang sempat nulis ulang DOM sebelum ini, tween-nya selalu nyantol ke node yang beneran hidup di layar.

Buat hero title yang juga ada count-up, saya gate sampai preloader kelar. #loader dapat kelas .is-done di window.load plus 1400ms, dan saya observasi lewat MutationObserver biar reveal plus count-up baru mulai pas font udah loaded dan halaman udah kelihatan:

const loader = document.querySelector('#loader');
const mo = new MutationObserver(() => {
  if (loader.classList.contains('is-done')) {
    startHeroReveal();
    mo.disconnect();
  }
});
mo.observe(loader, { attributes: true, attributeFilter: ['class'] });

Sebagai jaring pengaman, saya juga bikin reveal versi CSS murni buat kelas .word, jadi kalau SplitText error di suatu browser, teksnya tetap muncul, bukan malah stuck pudar.

Pelajaran

  • Kalau teks reveal nyangkut pudar padahal tween-nya complete, curigai node target udah ter-detach, bukan nilai opacity-nya yang salah.
  • Elemen ber-data-i18n dan reveal yang nulis ulang DOM itu dua penulis yang rebutan innerHTML yang sama. Jalanin reveal setelah i18n apply.
  • SplitText bisa re-split pas font load dan men-detach node. Tunggu document.fonts.ready sebelum split, atau pakai splitter yang sekali jalan.
  • Re-query node yang live tepat sebelum nge-tween, jangan nyimpen referensi node dari awal. Kalau ada yang nulis ulang DOM di antaranya, referensi lama jadi hantu.
  • Gate animasi yang bergantung font atau layout sampai window.load dan preloader kelar, biar nggak nge-animasi ke node yang belum settle.