Layout animation menganimasikan perubahan posisi atau ukuran yang datang dari layout: kartu yang pindah baris karena daftarnya diurutkan, kartu yang memanjang karena detailnya dibuka. Bayangin papan nama tamu di meja resepsi yang ditukar tempat. Kamu catat dulu posisi lamanya, pindahkan papannya langsung ke tempat baru, lalu dorong balik dengan tangan sampai kelihatan masih di tempat lama, dan lepaskan pelan-pelan. Tamu melihat papannya meluncur, padahal pindahnya terjadi seketika. Itu teknik FLIP: First, Last, Invert, Play.
🧪 Tebakan cepat: Daftar kamar Bu Sri diurutkan termurah dulu. Kamar Loteng (Rp 275.000) naik dari baris kedua ke baris pertama, 64 px. Tepat setelah diurutkan, sebelum frame pertama digambar, transform apa yang FLIP pasang di kartu Loteng:
translateY(-64px),translateY(64px), ataunone? Jawabannya ada di bagian "FLIP dengan tangan".
Kenapa gak bisa langsung pakai transition
transition cuma menganimasikan nilai CSS yang berubah. Saat kartu diurutkan ulang, gak ada properti yang berubah: elemennya pindah urutan di DOM, lalu layout menghitung tempat barunya. Gak ada yang bisa diinterpolasi, jadi kartunya meloncat dalam satu frame.
Menganimasikan top atau height setiap frame memang bisa, tapi mahal: di pelajaran 5 kartu yang naik lewat top menghitung layout 35 sampai 37 kali dalam 300 ms, lewat transform cuma 1 kali. FLIP memakai jalan kedua: biarkan layout meloncat sekali, lalu tutupi loncatan itu dengan transform.
FLIP dengan tangan
Tiga kartu di Lab tingginya 56 px dengan jarak 8 px, jadi tiap baris berjarak 64 px. Urutan aslinya Teras (Rp 350.000), Loteng (Rp 275.000), Kebun (Rp 425.000).
function flip(kartu, ubahDom) {
const first = new Map(kartu.map((el) => [el, el.getBoundingClientRect()])); // F: baca semua dulu
ubahDom(); // pindahkan di DOM
for (const el of kartu) {
const f = first.get(el);
const l = el.getBoundingClientRect(); // L: posisi baru
const dy = f.top - l.top; // I: First - Last
if (dy === 0) continue; // gak pindah
el.animate(
[{ transform: `translateY(${dy}px)` }, { transform: 'none' }], // P: lepaskan
{ duration: 300, easing: 'ease-in-out' }
);
}
}
flip([...list.children], () => urutkanTermurah());
- First:
getBoundingClientRect()membaca kotak tiap kartu sebelum apa pun berubah. Baca semuanya dulu, baru ubah DOM; membaca dan menulis berselang-seling memaksa layout berkali-kali (pelajaran berikutnya). - Last: setelah DOM berubah, baca lagi. Browser menghitung layout sekali di sini, tapi belum menggambar apa pun.
- Invert: selisihnya, First dikurangi Last, dipasang sebagai
transformsupaya kartu kelihatan masih di tempat lama. - Play: transform itu dilepas ke
none. Karena cumatransform, animasinya bisa jalan di compositor.
Yang diukur Lab saat diurutkan termurah dulu:
| Kartu | First (top) | Last (top) | Invert |
|---|---|---|---|
| Loteng | 64 | 0 | translateY(64px) |
| Teras | 0 | 64 | translateY(-64px) |
| Kebun | 128 | 128 | none, gak dianimasikan |
Itu jawaban tebakannya: translateY(64px). Kartu Loteng sudah di baris pertama, dan transform positif mendorongnya turun kembali ke tempat lamanya, lalu dilepas.
Ukuran: scale, dan anak yang ikut gepeng
Detail Kamar Teras dibuka: kartunya tumbuh dari 56 ke 112 px, dan dua kartu di bawahnya turun 56 px. Untuk ukuran, Invert-nya scale: tinggi lama dibagi tinggi baru, 56 / 112 = scaleY(0,5), dengan transform-origin di kiri atas. Kartu di bawahnya cukup translateY(-56px).
Masalahnya, scale ikut mengenai isi kartu. Di frame pertama teks "Kamar Teras" gepeng jadi separuh tingginya, lalu pelan-pelan kembali normal. Sudut melengkung 12 px juga ikut gepeng. Perbaikannya: beri isi kartu skala kebalikannya, 1 / 0,5 = scaleY(2), sehingga hasilnya 0,5 × 2 = 1.
Tapi kebalikan itu harus dihitung ulang di setiap frame. Dengan dua keyframe saja (2 ke 1), di tengah animasi induknya 0,75 dan isinya 1,5: 0,75 × 1,5 = 1,125, teksnya 12,5% kelebihan tinggi. Lab-nya memakai 20 keyframe yang dihitung satu per satu; Motion menghitungnya di setiap frame.
Motion: prop layout
Di React dengan Motion, semua langkah itu dikerjakan satu prop:
<motion.li
layout // FLIP otomatis di setiap render
transition={{ layout: { duration: 0.3, ease: 'easeInOut' } }}
style={{ borderRadius: 12 }} // ditulis di style supaya ikut dikoreksi
className="room"
>
<motion.div layout className="isi"> {/* anak ikut layout: skalanya dikoreksi */}
<b>Kamar Teras</b> <span>Rp 350.000 / malam</span>
{terbuka && <p>Gratis batal sampai 3 hari sebelum check-in.</p>}
</motion.div>
</motion.li>
layoutmembuat Motion mengukur posisi dan ukuran sebelum dan sesudah tiap render, lalu menganimasikan selisihnya dengantransform. Jadi layout animation adalah animasi otomatis saat posisi atau ukuran elemen berubah karena layout dihitung ulang (reflow).<motion.div layout>{isExpanded ? <FullContent /> : <Preview />}</motion.div>: saatisExpandedjaditrue, div-nya menganimasikan perubahan ukurannya dengan halus, tanpainitialatauanimate.- Anak yang juga punya
layoutmendapat koreksi skala dari Motion. Tanpa itu, anaknya gepeng seperti FLIP tanpa koreksi.borderRadiusdanboxShadowdikoreksi kalau ditulis distyle. layout="position"hanya menganimasikan posisi; ukurannya langsung loncat, jadi teks gak pernah gepeng.layout="size"kebalikannya.
layoutId: satu foto, dua tempat
Thumbnail "Teras pagi" di kartu dan foto besar di atas daftar adalah dua elemen berbeda. Beri keduanya layoutId yang sama, dan tampilkan salah satunya saja:
{!foto && <motion.button layoutId="foto-teras" className="thumb" onClick={() => setFoto(true)} />}
{foto && <motion.button layoutId="foto-teras" className="foto" onClick={() => setFoto(false)}>Teras pagi</motion.button>}
Saat thumbnail hilang dan foto besar muncul di render yang sama, Motion menganimasikan foto besar dari kotak thumbnail ke kotak barunya. Kalau beberapa komponen saling menggeser layout tapi gak di-render ulang bersamaan, bungkus dengan <LayoutGroup> supaya Motion mengukur semuanya bersama.
Tanpa library: view transitions
Browser punya versi bawaannya (pelajaran 9). Beri tiap kartu view-transition-name sendiri, lalu ubah DOM di dalam startViewTransition:
.room[data-id="teras"] { view-transition-name: kamar-teras; }
.room[data-id="loteng"] { view-transition-name: kamar-loteng; }
.room[data-id="kebun"] { view-transition-name: kamar-kebun; }
.thumb, .foto { view-transition-name: foto-teras; } /* gak pernah ada bersamaan */
if (document.startViewTransition) document.startViewTransition(() => urutkanTermurah());
else urutkanTermurah();
Browser memotret sebelum dan sesudah, lalu menggeser dan mengubah ukuran grup tiap nama. Namanya harus unik: dua kartu dengan nama yang sama membatalkan seluruh transisi. Didukung Chrome 111, Safari 18, Firefox 144; di React, ubah state-nya dengan flushSync di dalam callback.
⚠️ Jebakan yang sering ketemu
- Membaca Last terlalu cepat atau terlambat. Di React, ukur Last di
useLayoutEffect: setelah DOM berubah, sebelum digambar.useEffectbisa jalan setelah browser menggambar, jadi kartu sempat terlihat satu frame di tempat barunya lalu meloncat balik. - Baca-tulis berselang-seling. Baca semua First dulu, baru ubah DOM. Kalau tiap kartu dibaca lalu langsung ditulis, layout dipaksa dihitung berkali-kali.
- Teks gepeng.
scalemengenai isi kartu. Koreksi anaknya (layoutdi anak), atau pakailayout="position". - FLIP saat "Kurangi gerakan". Lewati animasinya dan biarkan kartu langsung pindah, atau ganti dengan fade singkat.
TL;DR: Perubahan layout (diurutkan, dibuka) gak punya nilai CSS yang bisa di-transition, dan menganimasikan top atau height menghitung layout di setiap frame. FLIP: baca First (getBoundingClientRect), ubah DOM, baca Last, pasang Invert (translateY(First - Last), scaleY(tinggi lama / tinggi baru)), lalu Play ke none. Di Lab, Loteng naik dari 64 ke 0 jadi translateY(64px), Teras turun jadi translateY(-64px), Kebun gak pindah. scale menggepengkan isi: Teras 56 ke 112 px mulai dari scaleY(0,5), anaknya butuh kebalikannya (2) di setiap frame, karena koreksi dua keyframe meleset 12,5% di tengah. Motion: layout mengerjakan FLIP di setiap render (anak dengan layout ikut dikoreksi, layout="position" hanya posisi), layoutId yang sama menyambungkan dua elemen berbeda (thumbnail ke foto besar). Tanpa library: startViewTransition dengan view-transition-name unik per kartu.