Ada bug yang bikin kesal bukan karena susah dicari, tapi karena setengah dari fiturmu jalan sempurna. Waktu itu saya bikin drawer navigasi slide-in untuk sebuah tema toko online. Animasi bukanya cantik: panel meluncur masuk, clip-path membuka bertahap, isinya menyusul dengan skew dan fade yang berlapis. Saya sempat puas.
Lalu saya klik tombol tutup, dan drawer-nya hilang begitu saja. Bukan menutup. Bukan mundur pelan. Lenyap dalam satu frame, seperti ada yang mencabut steker.
Gejalanya: setengah koreografi hilang
Yang bikin bingung, semua transisi yang saya tulis itu simetris. Tidak ada satu pun aturan CSS khusus untuk arah tutup. Saya cuma menempelkan .is-open di elemen drawer, dan semua anaknya bereaksi ke state itu. Secara logika, melepas class yang sama harusnya membalik semuanya dengan durasi yang sama persis.
Tapi tidak. Buka: mulus. Tutup: kedip, hilang.
Ini bukan tipe bug yang muncul di console. Tidak ada error, tidak ada warning, tidak ada properti yang gagal diparsing. CSS-nya valid, JS-nya jalan, hasilnya salah.
Tersangka pertama saya keliru semua
Karena tidak ada jejak error, saya menebak ke arah JavaScript. Beberapa dugaan yang saya buang satu per satu:
- JS-nya menghapus node-nya. Bukan. Elemennya masih ada di DOM setelah ditutup, cuma tidak kelihatan.
- Ada
display: noneyang nempel dari state lain. Bukan juga. Drawer-nya masih punya box, masih ikut menempati posisinya di layout. - Class-nya dicopot terlalu cepat oleh handler lain. Saya cek dengan menoggle class-nya manual dari devtools, tanpa JS sama sekali. Hasilnya tetap sama: klik tambah class, animasi jalan; klik hapus class, langsung hilang.
Poin terakhir itu yang menutup perkara. Kalau menoggle class secara manual saja sudah salah, masalahnya murni di CSS.
Akar masalahnya: visibility itu saklar, bukan slider
Base state drawer saya seperti ini, dan tampak sangat wajar:
.drawer {
visibility: hidden;
pointer-events: none;
}
.drawer.is-open {
visibility: visible;
pointer-events: auto;
}visibility: hidden dipakai supaya drawer yang sedang tertutup benar-benar keluar dari jangkauan: tidak bisa di-tab, tidak dibaca screen reader, tidak jadi jebakan fokus. Itu keputusan yang benar. Yang salah adalah kapan dia berlaku lagi.
Waktu .is-open dicopot, visibility: hidden dari base state langsung berlaku di frame itu juga. Tidak ada masa transisi, karena tanpa aturan transition yang eksplisit, visibility berpindah nilai secara diskret. Tidak ada posisi tengah antara terlihat dan tidak terlihat yang bisa dianimasikan.
Dan begitu elemen induknya tidak terlihat, semua anaknya ikut tidak terlihat. Transisi transform, clip-path, dan opacity di dalam drawer tetap berjalan, cuma jalannya di ruang yang tidak dirender. Selama 700 milidetik lebih, browser dengan rajin menganimasikan sesuatu yang sudah tidak ada di layar.
Jadi animasi tutupnya bukan tidak ada. Animasi tutupnya kepotong di frame pertama.
Perbaikannya: tunda flip visibility-nya
visibility memang tidak bisa dianimasikan bertahap, tapi dia bisa ditunda. Ini yang bikin triknya jalan: kasih transition-delay selama durasi tutup terpanjang, dengan durasi transisi nol.
.drawer {
visibility: hidden;
pointer-events: none;
transition: visibility 0s linear 760ms; /* samakan dengan transisi balik terpanjang */
}
.drawer.is-open {
visibility: visible;
pointer-events: auto;
transition: visibility 0s linear 0s; /* buka harus instan */
}
.drawer__panel { transform: translateX(100%); transition: transform 720ms ease; }
.drawer.is-open .drawer__panel { transform: translateX(0); }Baca dua aturan itu sebagai arah yang berbeda, bukan sebagai satu nilai:
- Waktu membuka, aturan
.is-openyang menang, dan delay-nya0s. Drawer langsung terlihat, jadi animasi masuknya kelihatan dari frame pertama. - Waktu menutup, class-nya dilepas dan yang berlaku kembali aturan base, lengkap dengan delay
760ms. Selama 760 milidetik itu drawer masihvisible, jadi panel sempat meluncur keluar dan seluruh koreografi baliknya kelihatan. Baru setelah ituhiddenmenyala.
Perhatikan asimetrinya. Delay hanya diperlukan di satu arah, dan itu kenapa dua aturan ini tidak boleh disederhanakan jadi satu.
Kenapa 760ms, bukan 720ms
Transisi terpanjang di dalam drawer saya adalah transform panel, 720ms. Angka delay-nya saya set sedikit lebih besar, 760ms, supaya ada sisa ruang. Kalau nilainya dipasang persis sama, kamu bermain di batas: sedikit saja pembulatan frame atau easing yang belum benar-benar mendarat, dan ujung animasinya masih bisa terpotong.
Yang lebih penting: angka ini harus diikat ke transisi terpanjang di dalam drawer, bukan ke perasaan. Kalau nanti kamu menambah elemen dengan transisi 900ms di dalam sana, angka 760 ini langsung jadi salah lagi dan bug-nya balik dalam bentuk yang lebih halus, cuma ekor animasinya yang hilang. Simpan angkanya di CSS variable dan pakai variabel yang sama untuk keduanya kalau drawer-mu bakal berkembang.
Jangan juga kelewat royal. Delay yang jauh lebih panjang dari animasinya berarti drawer tetap visible padahal secara visual sudah selesai menutup, dan itu mengembalikan sebagian masalah aksesibilitas yang tadi mau kamu hindari.
pointer-events sengaja tidak ikut ditunda
Di kode di atas, pointer-events berpindah tanpa delay sama sekali. Itu disengaja. Begitu pengguna menekan tombol tutup, drawer harusnya langsung berhenti menerima klik, meski secara visual dia masih meluncur keluar selama tiga per empat detik. Kalau delay-nya diikutkan juga, ada jendela di mana panel yang sedang pergi masih bisa diklik, dan itu terasa seperti bug yang lain lagi.
Jadi dua properti ini kelihatan mirip, tapi perlakuannya berbeda: visibility ditahan sampai animasi selesai, pointer-events dilepas sekarang juga.
Catatan soal alternatif yang lebih baru
Kalau kamu langsung kepikiran transition-behavior: allow-discrete, hati-hati dulu. Properti itu dibuat untuk properti yang perilaku animasinya benar-benar diskret seperti display, content-visibility, dan overlay. Menempelkannya ke visibility tidak mengubah apa pun, karena visibility sendiri sudah bisa ditransisikan tanpa bantuan itu.
Rute modern untuk kasus semacam ini sebenarnya pindah dari visibility: hidden ke display: none, lalu mentransisikan display dengan allow-discrete dan menambah blok @starting-style supaya animasi masuknya ikut jalan. Buat saya waktu itu tidak perlu sejauh itu. Trik delay cuma satu baris, tidak menuntut dukungan browser tertentu, dan gampang dibaca orang lain yang buka file itu enam bulan kemudian.
Yang saya bawa pulang
- Kalau animasi buka jalan tapi animasi tutup tidak, curigai properti diskret di base state duluan. Transisimu mungkin baik-baik saja, cuma dijalankan di elemen yang sudah tidak dirender.
visibilitytidak bisa dianimasikan bertahap, tapi bisa ditunda.transition: visibility 0s linear <durasi>adalah cara menahan elemen tetap ada sampai animasi keluarnya selesai.- Delay itu hanya untuk arah tutup. State terbuka harus menimpanya dengan
0ssupaya animasi masuk tidak ikut kehilangan awalannya. - Toggle class-nya manual dari devtools sebelum menyalahkan JavaScript. Kalau tanpa JS pun tetap salah, kamu baru saja memangkas separuh area pencarian.