Tạo ảnh động cho phần tử xuất hiện và biến mất

Trước đây, hiệu ứng chuyển đổi CSS không thể tạo ảnh động cho các phần tử khi chúng được thêm vào DOM lần đầu tiên hoặc khi thuộc tính display của chúng thay đổi từ none. Quy tắc @ @starting-style và transition-behavior: allow-discrete cung cấp một cách khai báo để tạo ảnh động xuất hiện và biến mất mượt mà.

Triển khai

1. Tạo ảnh động cho nút bật/tắt display: none

Cách tạo ảnh động cho một phần tử khi bật/tắt chế độ hiển thị của phần tử đó thông qua một thuộc tính (ví dụ: hidden có display: none):

  1. Xác định trạng thái hiển thị: Đặt giá trị thuộc tính cuối cùng (ví dụ: opacity: 1) trên lớp cơ sở.
  2. Xác định trạng thái bắt đầu của mục nhập: Sử dụng @starting-style để chỉ định các giá trị cần chuyển đổi từ khi phần tử xuất hiện.
  3. Bật hiệu ứng chuyển đổi riêng biệt: Thêm display vào thuộc tính transition và sử dụng transition-behavior: allow-discrete.
  4. Xác định trạng thái thoát: Đặt giá trị mục tiêu trong thuộc tính 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. Tạo ảnh động cho thao tác chèn và xoá DOM

Đối với các phần tử được thêm qua appendChild() hoặc bị xoá qua remove():

  • Mục nhập: Sử dụng @starting-style như minh hoạ ở trên. Trình duyệt sẽ tự động phát hiện thay đổi về kiểu từ "không có gì" thành kiểu ban đầu của phần tử và kích hoạt hiệu ứng chuyển đổi từ các giá trị @starting-style.
  • Xoá: Vì element.remove() diễn ra tức thì và không tự kích hoạt hiệu ứng chuyển đổi CSS, nên bạn phải kích hoạt hiệu ứng chuyển đổi thoát trước và đợi hiệu ứng này kết thúc rồi mới xoá nút khỏi 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();

Các quy tắc ràng buộc và hỗ trợ tiếp cận

  • BẮT BUỘC: Sử dụng transition-behavior: allow-discrete khi chuyển đổi display. Nếu không có thuộc tính này, phần tử sẽ biến mất ngay lập tức khi thoát.
  • KHÔNG sử dụng allow-discrete trong cú pháp viết tắt transition — điều này sẽ khiến các trình duyệt cũ bỏ qua toàn bộ nội dung khai báo transition. Ngoại trừ các trường hợp sử dụng mà bạn muốn, hãy sử dụng một nội dung khai báo transition-behavior: allow-discrete riêng biệt.
  • BẮT BUỘC: Sử dụng @starting-style cho ảnh động vào cảnh. Trình duyệt sẽ bỏ qua hiệu ứng chuyển đổi trong lần cập nhật kiểu đầu tiên của một phần tử (kết xuất ban đầu hoặc thay đổi display: none) trừ phi bạn cung cấp thuộc tính này.
  • NÊN: Thêm overlay vào danh sách transition nếu tạo ảnh động cho các phần tử lớp trên cùng như <dialog> hoặc popover để đảm bảo các phần tử đó vẫn ở lớp trên cùng trong ảnh động thoát.
  • NÊN: Tôn trọng lựa chọn ưu tiên của người dùng về việc giảm chuyển động bằng cách sử dụng truy vấn phương tiện prefers-reduced-motion.
  • KHÔNG NÊN: Dựa vào @starting-style cho ảnh động thoát; thuộc tính này chỉ xác định điểm bắt đầu cho hiệu ứng chuyển đổi vào. Ảnh động thoát được xác định bằng quá trình chuyển đổi sang trạng thái ẩn.

Chiến lược dự phòng

Dự phòng và hỗ trợ trình duyệt cho transition-behavior

Firefox 129 trở lên phân tích cú pháp transition-behavior: allow-discrete (CSS.supports('transition-behavior', 'allow-discrete') trả về true) mà không thực sự chuyển đổi thuộc tính display (lỗi Firefox 1882408), khiến các phần tử biến mất ngay lập tức khi thoát.

Để phát hiện một cách đáng tin cậy tính năng hỗ trợ chuyển đổi display riêng biệt, hãy thăm dò xem display được tính toán của một phần tử tạm thời có hiển thị khi chuyển đổi sang none hay không, thay vì chỉ dựa vào 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;
}

Dự phòng thoát khi không hỗ trợ hiệu ứng chuyển đổi display riêng biệt

Ảnh động xuất hiện sử dụng @starting-style hoạt động trên tất cả trình duyệt hiện đại mà không cần JavaScript. Khi không hỗ trợ các hiệu ứng chuyển đổi display riêng biệt (!canTransitionDisplay()), việc đặt hidden sẽ áp dụng display: none ngay lập tức và bỏ qua ảnh động thoát (cả khi ẩn và trước element.remove()). Tách trạng thái thoát hình ảnh ([data-closing]) khỏi display: none ([hidden]) để opacity và translate hoàn tất ảnh động trước khi ẩn hoặc xoá phần tử. Thay thế các quy tắc .card[hidden] ở bước 1 (bao gồm cả cơ chế ghi đè prefers-reduced-motion) bằng:

.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();
}