Раньше переходы CSS не могли анимировать элементы, когда они впервые добавлялись в DOM или когда их свойство display менялось с none. At-правило @starting-style и transition-behavior: allow-discrete позволяют создавать плавные анимации появления и исчезновения.
Реализация
1. Анимация переключателей display: none
Чтобы анимировать элемент при переключении его видимости с помощью атрибута (например, hidden с display: none):
- Определите видимое состояние. Задайте конечные значения свойств (например,
opacity: 1) в базовом классе. - Определите начальное состояние. С помощью
@starting-styleукажите значения, от которых будет начинаться переход, когда элемент становится видимым. - Включите дискретные переходы. Добавьте
displayв свойствоtransitionи используйтеtransition-behavior: allow-discrete. - Определите конечное состояние. Задайте целевые значения в атрибуте
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. Анимация вставки и удаления DOM
Для элементов, добавленных с помощью appendChild() или удаленных с помощью remove():
- Запись. Используйте
@starting-style, как показано выше. Браузер автоматически обнаружит изменение стиля с "ничего" на исходные стили элемента и запустит переход от значений@starting-style. - Удаление. Поскольку
element.remove()выполняется мгновенно и не запускает переход CSS, сначала нужно запустить переход выхода и дождаться его завершения, прежде чем удалять узел из 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();
Ограничения и специальные возможности
- ОБЯЗАТЕЛЬНО. Используйте
transition-behavior: allow-discreteпри переходе наdisplay. Без него элемент будет мгновенно исчезать при выходе. - НЕ используйте
allow-discreteв сокращенной записиtransition, иначе старые браузеры будут игнорировать всю декларациюtransition. Если это нежелательно, используйте отдельную декларациюtransition-behavior: allow-discrete. - ОБЯЗАТЕЛЬНО. Используйте
@starting-styleдля анимации появления. Браузеры пропускают переходы при первом обновлении стиля элемента (первоначальный рендеринг или изменениеdisplay: none), если это не предусмотрено. - РЕКОМЕНДУЕТСЯ. Включите
overlayв списокtransition, если анимируете элементы верхнего слоя, такие как<dialog>илиpopover, чтобы они оставались в верхнем слое во время анимации выхода. - РЕКОМЕНДУЕТСЯ учитывать предпочтения пользователей в отношении уменьшения движения, используя медиазапрос
prefers-reduced-motion. - НЕ используйте
@starting-styleдля анимации исчезновения. Это свойство определяет только начальную точку для анимации появления. Анимация выхода определяется переходом в скрытое состояние.
Резервные стратегии
Резервные варианты и поддержка браузерами transition-behavior
Firefox 129+ анализирует transition-behavior: allow-discrete (CSS.supports('transition-behavior', 'allow-discrete') возвращает true) без фактического перехода свойства display (ошибка Firefox 1882408), в результате чего элементы исчезают сразу после выхода.
Чтобы надежно определить поддержку дискретных переходов display, проверьте, остается ли вычисленное значение display временного элемента видимым при переходе к none, а не полагайтесь только на 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;
}
Резервный вариант выхода, если дискретные переходы display не поддерживаются
Анимация входа с помощью @starting-style работает во всех современных браузерах без JavaScript. Если дискретные переходы display не поддерживаются (!canTransitionDisplay()), то при установке hidden сразу применяется display: none и пропускается анимация выхода (как при скрытии, так и перед element.remove()). Отделите визуальное состояние выхода ([data-closing]) от display: none ([hidden]), чтобы анимация opacity и translate завершилась до скрытия или удаления элемента. Замените правила .card[hidden] из шага 1 (включая переопределение prefers-reduced-motion) на следующие:
.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();
}