Layout Animation — Web Animation

Layout Animation Layout animation menganimasi perubahan posisi dan ukuran elemen yang disebabkan oleh perubahan layout (reflow). Ini adalah salah satu animasi…

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), atau none? 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());

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>

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

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.

Yang akan kamu pelajari