Анимация элементов верхнего слоя

Элементы, которые отображаются в верхнем слое (например, <dialog>, элементы с атрибутом popover или подсказки), раньше было сложно анимировать, поскольку они переключаются между display: none и видимым состоянием. Современный CSS предоставляет @starting-style, transition-behavior: allow-discrete и свойство overlay, чтобы обеспечить плавные переходы входа и выхода для этих элементов. Обратите внимание, что в примерах ниже используется встроенная вложенность CSS.

Реализация

1. Включить дискретные переходы

Чтобы анимировать свойство display, необходимо задать transition-behavior: allow-discrete. Это позволяет элементу оставаться видимым во время перехода к выходу. Если вы используете сокращенные свойства перехода, разместите transition-behavior: allow-discrete после них, чтобы они не отменяли его действие.

2. Свойство overlay

Когда элемент перемещается в верхний слой или из него, он должен перейти в свойство overlay. Это гарантирует, что элемент останется в верхнем слое на протяжении всей анимации, предотвращая его преждевременное обрезание другими элементами или областью просмотра.

3. Анимация входа с @starting-style

Используйте правило @starting-style, чтобы определить стили, из которых должен переходить элемент при первом рендеринге или когда его свойство display меняется со значения none.

4. Анимация фона

Псевдоэлемент ::backdrop можно анимировать аналогичным образом, применив переходы к его собственным свойствам.

Пример

/* 1. Define the visible (open) state */
dialog[open],
[popover]:popover-open {
  opacity: 1;
  transform: scale(1);

  /* 2. Define the starting state for entry (must come after open state) */
  @starting-style {
    opacity: 0;
    transform: scale(0.9);
  }
}

/* 3. Define the base (closed/exit) state and transitions */
dialog,
[popover] {
  opacity: 0;
  transform: scale(0.9);

  /* MANDATORY: transition display and overlay for top-layer elements */
  transition-property: opacity, transform, display, overlay;
  transition-duration: 0.3s;
  transition-timing-function: ease-out;
  /* Applies to discrete properties like display and overlay */
  transition-behavior: allow-discrete; /* Note: be sure to write this after the shorthand */
}

/* 4. Animate the backdrop */
dialog::backdrop,
[popover]::backdrop {
  background-color: rgba(0, 0, 0, 0);
  transition-property: background-color, display, overlay;
  transition-duration: 0.3s;
  transition-timing-function: ease-out;
  transition-behavior: allow-discrete;
}

dialog[open]::backdrop,
[popover]:popover-open::backdrop {
  background-color: rgba(0, 0, 0, 0.5);

  @starting-style {
    background-color: rgba(0, 0, 0, 0);
  }
}

/* 5. Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
  dialog,
  [popover] {
    /* Disable movement and shorten duration for a simple fade */
    transform: none;
    transition-duration: 0.1s;
  }

  @starting-style {
    dialog[open],
    [popover]:popover-open {
      transform: none;
    }
  }
}

Ограничения и специальные возможности

  • ОБЯЗАТЕЛЬНО. Добавьте overlay в список transition для любого элемента, который перемещается на верхний слой или с него.
  • ОБЯЗАТЕЛЬНО. Используйте allow-discrete для перехода на свойство display.
  • ОБЯЗАТЕЛЬНО. Учитывайте настройки пользователя, касающиеся уменьшения движения, с помощью prefers-reduced-motion, упрощая переходы (например, удаляя преобразования и сокращая длительность).
  • РЕКОМЕНДУЕТСЯ. Поместите блок @starting-style внутрь селектора состояния open или после него, чтобы обеспечить правильное каскадирование.
  • НЕЛЬЗЯ использовать @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;
}

Резервный переход для верхнего слоя

Анимации входа работают на чистом CSS во всех браузерах, поддерживающих @starting-style. Класс .is-opening не требуется, поскольку переходы входа не зависят от overlay или дискретных переходов display.

Для анимации выхода требуется поддержка как overlay, так и дискретных переходов display. Если что-то из этого не поддерживается (например, в Firefox и Safari), оберните селекторы открытого состояния в :is() и добавьте :where(:not([data-closing])) (вложенные &::backdrop и @starting-style), чтобы настройка data-closing запускала переход выхода, пока элемент остается в верхнем слое, а затем дождитесь завершения getAnimations(), прежде чем вызывать .close() или .hidePopover():

:is(dialog[open], [popover]:popover-open):where(:not([data-closing])) {
  opacity: 1;
  transform: scale(1);

  @starting-style {
    opacity: 0;
    transform: scale(0.9);
  }

  &::backdrop {
    background-color: rgb(0 0 0 / 0.5);

    @starting-style {
      background-color: transparent;
    }
  }
}
// Evaluate lazily: canTransitionDisplay() needs document.body, so a top-level
// const would be permanently false if this script runs in <head>.
function supportsTopLayerExit() {
  return window.CSS?.supports?.('overlay', 'auto') && canTransitionDisplay();
}

async function closeTopLayer(element) {
  if (!supportsTopLayerExit()) {
    element.setAttribute('data-closing', '');
    const animations = element.getAnimations({ subtree: true });
    if (animations.length > 0) {
      await Promise.race([
        Promise.allSettled(animations.map((a) => a.finished)),
        new Promise((r) => setTimeout(r, 2000)),
      ]);
    }
    if (!element.hasAttribute('data-closing')) return;
    element.removeAttribute('data-closing');
  }

  element.close();
  // Or for popover:
  // element.hidePopover();
}

// Route native close requests (Esc, closedby light dismiss) through the same
// helper so they animate too, instead of closing instantly.
dialog.addEventListener('cancel', (event) => {
  event.preventDefault();
  closeTopLayer(dialog);
});

Перехватить закрытие всплывающего окна по клику вне его области и переключатели popovertarget нельзя (beforetoggle можно отменить только при открытии), поэтому в браузерах, где требуется резервный вариант, эти выходы происходят мгновенно. Если важна анимация выхода, добавьте явный элемент управления закрытием, который вызывает closeTopLayer().

Резервные варианты и поддержка Popover в браузерах

Popover API в основном представляет собой прогрессивное улучшение, но его определяющие характеристики – продвижение верхнего слоя, легкое отклонение и вызов popovertarget – не имеют эквивалента только для CSS. Для старых браузеров нужен полифил или резервный вариант, если вы не хотите его использовать.

Полифил. Чтобы обеспечить поддержку атрибута popover в старых браузерах, условно загрузите @oddbird/popover-polyfill. ОБЯЗАТЕЛЬНО. Проверьте наличие свойства popover в HTMLElement.prototype и загрузите полифил, только если встроенная поддержка отсутствует. Не загружайте его без необходимости.

С помощью сборщика или карты импорта:

// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
  import("@oddbird/popover-polyfill");
}

Если вы не используете сборщик, импортируйте из CDN в <script type="module">:

<script type="module">
  if (!("popover" in HTMLElement.prototype)) {
    import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
  }
</script>

Ограничение, связанное со стилями. Полифил не может определить настоящий псевдокласс :popover-open, поэтому вместо него применяется класс .\:popover-open. ОБЯЗАТЕЛЬНО. Объедините эти два правила с помощью :is() или :where(), иначе браузеры, не поддерживающие :popover-open, будут игнорировать правило целиком.

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

В качестве альтернативы для устаревшего резервного варианта без полифила используйте position: fixed и вручную рассчитайте координаты с помощью getBoundingClientRect() или используйте позиционирование по умолчанию с помощью inset: auto, если это допустимо для вашего случая.