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):
- 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ở. - 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. - Bật hiệu ứng chuyển đổi riêng biệt: Thêm
displayvào thuộc tínhtransitionvà sử dụngtransition-behavior: allow-discrete. - 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-stylenhư 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-discretekhi chuyển đổidisplay. 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-discretetrong cú pháp viết tắttransition— điều này sẽ khiến các trình duyệt cũ bỏ qua toàn bộ nội dung khai báotransition. 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áotransition-behavior: allow-discreteriêng biệt. - BẮT BUỘC: Sử dụng
@starting-stylecho ả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 đổidisplay: none) trừ phi bạn cung cấp thuộc tính này. - NÊN: Thêm
overlayvào danh sáchtransitionnếu tạo ảnh động cho các phần tử lớp trên cùng như<dialog>hoặcpopoverđể đả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-stylecho ả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();
}