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):
- Tentukan status yang terlihat: Tetapkan nilai properti akhir (misalnya,
opacity: 1) pada class dasar. - Tentukan status awal entri: Gunakan
@starting-styleuntuk menentukan nilai yang akan ditransisikan dari saat elemen terlihat. - Mengaktifkan transisi diskret: Sertakan
displaydi propertitransitiondan gunakantransition-behavior: allow-discrete. - 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-styleseperti 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-discretesaat mentransisikandisplay. Tanpa itu, elemen akan langsung menghilang saat keluar. - JANGAN gunakan
allow-discretedalam singkatantransition— hal ini akan membuat browser lama mengabaikan seluruh deklarasitransition. Kecuali dalam kasus penggunaan yang diinginkan, gunakan deklarasitransition-behavior: allow-discreteterpisah. - WAJIB: Gunakan
@starting-styleuntuk animasi masuk. Browser akan melewati transisi pada update gaya pertama elemen (rendering awal atau perubahandisplay: none) kecuali jika hal ini disediakan. - LAKUKAN: Sertakan
overlaydalam daftartransitionjika menganimasikan elemen lapisan atas seperti<dialog>ataupopoveruntuk 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-styleuntuk 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();
}