Tema WordPress premium yang saya bangun ini berisi tiga arah desain yang berdiri sendiri-sendiri. Yang ketiga akhirnya duduk di halaman depan, dan seluruh kesan pertamanya bertumpu pada tipografi: satu judul hero raksasa yang masuk huruf per huruf, ditambah blok-blok teks yang naik baris per baris. Dua-duanya digerakkan SplitText di atas GSAP 3.13, dengan semua pustaka dan fontnya di-host sendiri.
Geraknya jalan. Pemenggalan barisnya tidak. SplitText memotong baris di posisi yang salah, dan konsol ikut buka suara dengan peringatan font-timing miliknya sendiri, yang muncul persis ketika split berjalan sebelum web font selesai dimuat.
Gejalanya
Reveal per baris cuma sebagus pembagian barisnya. Kalau SplitText membungkus baris pertama di tempat yang keliru, yang naik ke layar bukan baris yang kamu tulis, melainkan potongan yang kebetulan terbentuk pada saat pengukuran. Di judul hero, kesalahan sekecil apa pun jadi besar sendiri, karena tipenya memang besar. Tokennya sendiri berbunyi begini:
.h1{font-family:var(--font-display);font-weight:600;font-size:15rem;line-height:.8;letter-spacing:-.72rem;text-transform:uppercase}Angka-angka itu tidak menyisakan ruang toleransi. Tracking negatifnya 0,72rem per huruf, dan diukur terhadap font-size-nya sendiri itu 0,72 dibagi 15, alias 4,8 persen dari font-size yang ditarik balik setiap kali ada satu huruf. Sepuluh huruf saja sudah memendekkan lebar barisnya 10 kali 0,72rem, yaitu 7,2rem. Kotak barisnya juga lebih pendek dari fontnya, 0,8 kali 15rem sama dengan 12rem. Di desktop, font-size-nya sendiri diturunkan ke 13rem, karena 15rem meluber di lebar 1440 begitu padding horizontalnya diterapkan. Jadi begitu lebar per hurufnya diukur dari font yang salah, titik lompat barisnya ikut meleset, dan pada ukuran sebesar ini selisihnya kelihatan dari seberang ruangan.
Kenapa ini terjadi
Penjelasan yang saya catat sendiri di spec, dan yang sampai sekarang paling masuk akal buat saya: SplitText mengukur teks dengan font yang sedang terender saat itu juga. Kalau split jalan sebelum font kustomnya dimuat, yang dia ukur adalah kotak baris milik font cadangan, sehingga pemenggalan barisnya mendarat di tempat yang salah.
Dua hal di CSS bikin skenario itu bukan sekadar kemungkinan, tapi keadaan default. Pertama, semua muka font kustomnya dideklarasikan dengan font-display: swap, yang persis berarti browser disuruh menggambar teksnya lebih dulu dengan font cadangan lalu menukarnya belakangan:
@font-face {
font-family: 'ThemeSans';
src: url('./themesans-400.woff2') format('woff2');
font-weight: 400;
font-display: swap;
font-style: normal;
}Kedua, token fontnya memang menyediakan cadangan itu secara eksplisit. Token judul dan token badan sama-sama menaruh font kustomnya di depan, dengan Arial dan Helvetica Neue sebagai jaring di belakangnya:
--font-display:'ThemeSans',Arial,'Helvetica Neue',sans-serif;
--font-body:'ThemeSans',Arial,'Helvetica Neue',sans-serif;Gabungan keduanya berarti ada jendela waktu di awal muat halaman ketika teksnya sudah punya bentuk yang bisa diukur, tapi bentuk itu milik Arial, bukan milik muka yang nanti benar-benar dipakai. Huruf yang berbeda punya lebar maju yang berbeda, jadi jumlah karakter yang muat dalam satu baris juga berbeda. SplitText yang jalan di dalam jendela itu mengunci pembagian baris di atas pengukuran yang sebentar lagi kedaluwarsa.
Perbaikannya
Perbaikannya muat dalam satu kalimat: seluruh kerja SplitText digerbangi di document.fonts.ready. Yang menarik, di kode ini gerbangnya berbentuk dua rupa berbeda, karena dua tempatnya punya kebutuhan berbeda.
Di hero, statusnya diperiksa dulu. Kalau font belum selesai, judulnya tetap dibuat terlihat supaya tidak ada yang menunggu di balik layar kosong, split-nya ditunda ke document.fonts.ready, dan subjudul serta eyebrow-nya tetap beranimasi seperti biasa:
if (!document.fonts || document.fonts.status !== 'loaded') {
gsap.set(heroTitle, { visibility: 'visible' });
document.fonts.ready.then(splitHero);
gsap.from('.hero__sub, .hero__eyebrow', { opacity: 0, duration: 0.6, ease: 'power3.out', delay: 0.6 });
return;
}
splitHero();Yang ditunda itu isinya memecah judul jadi kata dan karakter dengan mask di level karakter, lalu menaikkan tiap karakter dari yPercent: 100. Hero-nya sendiri tidak dipanggil langsung, dia menunggu event kustom yang menandai halaman siap mulai:
function splitHero() {
var split = new SplitText(heroTitle, { type: 'words,chars', charsClass: 'char-inner', mask: 'chars' });
gsap.set(heroTitle, { visibility: 'visible' });
gsap.from(split.chars, { yPercent: 100, duration: 1.4, ease: 'power4.inOut', stagger: 0.03 });
}
document.addEventListener('theme:start', heroIntro);Di modul reveal per baris, bentuknya lain. Di sini tidak ada yang perlu tampil lebih dulu, jadi seluruh loop split dibungkus satu Promise sekaligus, lengkap dengan jalan mundur kalau document.fonts memang tidak ada. Dan karena memecah teks jadi baris mengubah tinggi elemennya, ScrollTrigger.refresh() dipanggil setelah semua split selesai supaya posisi trigger dihitung ulang di atas layout yang sudah final:
if (!reduce && window.SplitText) {
(document.fonts ? document.fonts.ready : Promise.resolve()).then(function () {
gsap.utils.toArray('[data-split-lines]').forEach(function (el) {
var split = new SplitText(el, { type: 'lines', linesClass: 'line-inner', mask: 'lines' });
// ... reveal per baris
});
ScrollTrigger.refresh();
});
} else {
document.querySelectorAll('[data-split-lines]').forEach(function (el) { el.style.visibility = 'visible'; });
}Menunda split berarti ada jeda ketika teksnya belum siap dipamerkan, dan itu ditangani di CSS, bukan di JS. Keadaan awal yang menyembunyikan judul hero dan semua elemen bergaris hanya aktif kalau JavaScript hidup, jadi kalau JS-nya tidak jalan sama sekali, semuanya tetap terbaca:
/* JS-gated initial states (safe: no-JS keeps everything visible) */
html.js .hero__title .h1{visibility:hidden}
html.js [data-split-lines]{visibility:hidden}Yang berubah setelahnya
Peringatan font-timing SplitText hilang oleh gerbang fonts.ready itu, dan di ronde audit yang sama konsolnya tercatat nol error. Arah desain ketiga sejak itu dideskripsikan apa adanya di catatan mekaniknya, char dan line reveal SplitText yang sudah bergerbang fonts.ready.
Satu catatan kejujuran soal verifikasinya. Ronde itu saya verifikasi lewat probe DOM, satu per satu, mekanik demi mekanik. Yang menangkap bahwa verifikasi seperti itu cuma menyentuh DOM dan sama sekali tidak menyentuh apa yang terlihat justru klien, dan bug-bug berikutnya ditemukan dari tangkapan layar mereka. Konsol yang bersih memang bukti bahwa peringatannya hilang. Dia bukan bukti bahwa barisnya patah di tempat yang benar.
Pelajaran
- SplitText mengukur dengan font yang sedang terender, bukan dengan font yang akan terender. Kalau split-nya dipanggil terlalu awal, yang terekam adalah geometri font cadangan.
font-display: swapplus stack cadangan yang lengkap itu bagus untuk kecepatan tampil, tapi keduanya bersama-sama justru menjamin adanya jendela ketika teks sudah terukur dengan muka yang salah.- Gerbangnya tidak harus satu bentuk. Untuk hero yang harus tampil cepat, periksa
document.fonts.statuslalu tunda split-nya. Untuk modul yang tidak buru-buru, bungkus seluruh loop dalamdocument.fonts.readysekali saja. - Split per baris mengubah tinggi elemen, jadi panggil
ScrollTrigger.refresh()setelah semua split beres. - Sembunyikan keadaan awal lewat aturan yang hanya aktif saat JS hidup. Kalau JS-nya gagal, teksnya tetap terbaca alih-alih hilang menunggu gerbang yang tidak pernah dibuka.
- Nol peringatan di konsol dan tampilan yang benar adalah dua klaim berbeda. Yang pertama bisa diukur probe, yang kedua butuh mata.