Menganimasikan Masuk dan Keluar Elemen

Sebelumnya, transisi CSS tidak dapat menganimasikan elemen saat pertama kali ditambahkan ke DOM atau saat properti display-nya berubah dari none. Aturan-at @starting-style dan transition-behavior: allow-discrete menyediakan cara deklaratif untuk membuat animasi masuk dan keluar yang lancar.

Penerapan

1. Menganimasikan Tombol display: none

Untuk menganimasikan elemen saat mengalihkan visibilitasnya melalui atribut (misalnya, hidden dengan display: none):

  1. Tentukan status yang terlihat: Tetapkan nilai properti akhir (misalnya, opacity: 1) pada class dasar.
  2. Tentukan status awal entri: Gunakan @starting-style untuk menentukan nilai yang akan ditransisikan dari saat elemen terlihat.
  3. Mengaktifkan transisi diskret: Sertakan display di properti transition dan gunakan transition-behavior: allow-discrete.
  4. Tentukan status keluar: Tetapkan nilai target di atribut hidden.
.card {
  display: block;
  opacity: 1;
  translate: 0;
  /* MANDATORY: Use transition-behavior: allow-discrete for display transition */
  transition:
    display 0.4s,
    opacity 0.4s ease-out,
    translate 0.4s ease-out;
  transition-behavior: allow-discrete;
}

/* Entry animation: transition FROM these values when first rendered */
@starting-style {
  .card {
    opacity: 0;
    translate: 0 -20px;
  }
}

/* Exit animation: transition TO these values when hidden */
.card[hidden] {
  display: none;
  opacity: 0;
  translate: 0 -20px;
}

/* Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
  .card {
    /* Disable movement and shorten duration for a simple fade */
    translate: none;
    transition-duration: 0.1s;
  }

  @starting-style {
    .card {
      translate: none;
    }
  }

  .card[hidden] {
    translate: none;
  }
}

2. Menganimasikan Penyisipan dan Penghapusan DOM

Untuk elemen yang ditambahkan melalui appendChild() atau dihapus melalui remove():

  • Entri: Gunakan @starting-style seperti yang ditunjukkan di atas. Browser akan otomatis mendeteksi perubahan gaya dari "tidak ada" ke gaya awal elemen dan memicu transisi dari nilai @starting-style.
  • Penghapusan: Karena element.remove() bersifat instan dan tidak memicu transisi CSS dengan sendirinya, Anda harus memicu transisi keluar terlebih dahulu dan menunggu hingga selesai sebelum menghapus node dari DOM.
// 1. Trigger exit transition
element.hidden = true;

// 2. Wait for all active transitions/animations to finish,
//    with a failsafe timeout in case an animation never ends (e.g. for looping animations)
const animations = element.getAnimations();
if (animations.length > 0) {
  await Promise.race([
    // Promise.allSettled ensures we wait even if some animations fail
    Promise.allSettled(animations.map(a => a.finished)),
    new Promise(r => setTimeout(r, 2000))
  ]);
}

// 3. Finally remove the node from the DOM
element.remove();

Batasan & Aksesibilitas

  • WAJIB: Gunakan transition-behavior: allow-discrete saat mentransisikan display. Tanpa itu, elemen akan langsung menghilang saat keluar.
  • JANGAN gunakan allow-discrete dalam singkatan transition — hal ini akan membuat browser lama mengabaikan seluruh deklarasi transition. Kecuali dalam kasus penggunaan yang diinginkan, gunakan deklarasi transition-behavior: allow-discrete terpisah.
  • WAJIB: Gunakan @starting-style untuk animasi masuk. Browser akan melewati transisi pada update gaya pertama elemen (rendering awal atau perubahan display: none) kecuali jika hal ini disediakan.
  • LAKUKAN: Sertakan overlay dalam daftar transition jika menganimasikan elemen lapisan atas seperti <dialog> atau popover untuk memastikan elemen tersebut tetap berada di lapisan atas selama animasi keluar.
  • LAKUKAN: Hormati preferensi pengguna untuk mengurangi gerakan menggunakan kueri media prefers-reduced-motion.
  • JANGAN: Mengandalkan @starting-style untuk animasi keluar; ini hanya menentukan titik awal untuk transisi masuk. Animasi keluar ditentukan oleh transisi ke status tersembunyi.

Strategi penggantian

Penggantian & dukungan browser untuk transition-behavior

Firefox 129+ mengurai transition-behavior: allow-discrete (CSS.supports('transition-behavior', 'allow-discrete') menampilkan true) tanpa benar-benar mentransisikan properti display (bug Firefox 1882408), sehingga menyebabkan elemen langsung menghilang saat keluar.

Untuk mendeteksi dukungan transisi display diskret dengan andal, periksa apakah display yang dihitung dari elemen sementara tetap terlihat saat ditransisikan ke none, bukan hanya mengandalkan CSS.supports('transition-behavior', 'allow-discrete'):

let supportsDisplayTransition;
function canTransitionDisplay() {
  if (supportsDisplayTransition !== undefined) return supportsDisplayTransition;
  if (!window.CSS?.supports?.('transition-behavior', 'allow-discrete') || !document.body) {
    return false;
  }
  const probe = document.createElement('div');
  // The shorthand is intentional here: browsers that don't parse allow-discrete
  // drop the whole declaration, so display: none applies instantly and the probe
  // correctly returns false. !important guards against global reduced-motion
  // resets like `* { transition: none !important }`.
  probe.style.cssText = 'transition: display 1s allow-discrete !important; display: block;';
  document.body.appendChild(probe);
  getComputedStyle(probe).display;
  probe.style.display = 'none';
  supportsDisplayTransition = getComputedStyle(probe).display === 'block';
  probe.remove();
  return supportsDisplayTransition;
}

Keluar dari penggantian saat transisi display diskret tidak didukung

Animasi masuk menggunakan @starting-style berfungsi di semua browser modern tanpa JavaScript. Jika transisi display diskret tidak didukung (!canTransitionDisplay()), menyetel hidden akan langsung menerapkan display: none dan melewati animasi keluar (baik saat menyembunyikan maupun sebelum element.remove()). Pisahkan status keluar visual ([data-closing]) dari display: none ([hidden]) agar opacity dan translate menyelesaikan animasi sebelum menyembunyikan atau menghapus elemen. Ganti aturan .card[hidden] dari langkah 1 (termasuk penggantian prefers-reduced-motion) dengan:

.card:where([hidden], [data-closing]) {
  opacity: 0;
  translate: 0 -20px;
}

.card[hidden] {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .card:where([hidden], [data-closing]) {
    translate: none;
  }
}
async function hideElement(el) {
  if (canTransitionDisplay()) {
    el.hidden = true;
  } else {
    el.setAttribute('data-closing', '');
  }

  const animations = el.getAnimations();
  if (animations.length > 0) {
    await Promise.race([
      Promise.allSettled(animations.map((a) => a.finished)),
      new Promise((r) => setTimeout(r, 2000)),
    ]);
  }

  // Skipped if showElement() cancelled the close mid-animation.
  if (el.hasAttribute('data-closing')) {
    el.removeAttribute('data-closing');
    el.hidden = true;
  }
}

function showElement(el) {
  el.removeAttribute('data-closing');
  el.hidden = false;
}

async function removeElement(el) {
  await hideElement(el);
  el.remove();
}