Animowanie pojawiania się i znikania elementów

Funkcje internetowe: @starting-style transition-behavior

W przeszłości przejścia CSS nie mogły animować elementów, gdy były one po raz pierwszy dodawane do DOM lub gdy ich właściwość display zmieniała się z none. Reguła at-rule @starting-style i transition-behavior: allow-discrete umożliwiają deklaratywne tworzenie płynnych animacji wejścia i wyjścia.

Implementacja

1. Animowanie przełączników display: none

Aby animować element, gdy jego widoczność jest przełączana za pomocą atrybutu (np. hidden z display: none):

  1. Zdefiniuj stan widoczności: ustaw ostateczne wartości właściwości (np. opacity: 1) w klasie podstawowej.
  2. Zdefiniuj stan początkowy wejścia: użyj @starting-style, aby określić wartości, od których ma się zaczynać przejście, gdy element stanie się widoczny.
  3. Włącz przejścia dyskretne: we właściwości transition uwzględnij display i użyj transition-behavior: allow-discrete.
  4. Zdefiniuj stan wyjściowy: ustaw wartości docelowe w atrybucie 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. Animowanie wstawiania i usuwania elementów DOM

W przypadku elementów dodanych za pomocą appendChild() lub usuniętych za pomocą remove():

  • Wpis: użyj @starting-style, jak pokazano powyżej. Przeglądarka automatycznie wykryje zmianę stylu z „nic” na początkowe style elementu i wywoła przejście z wartości @starting-style.
  • Usuwanie: ponieważ element.remove() jest natychmiastowe i nie wywołuje samodzielnie przejścia CSS, musisz najpierw wywołać przejście wyjściowe i poczekać, aż się zakończy, zanim usuniesz węzeł z 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();

Ograniczenia i ułatwienia dostępu

  • OBOWIĄZKOWO: użyj transition-behavior: allow-discrete podczas przejścia display. Bez niego element natychmiast zniknie podczas wyjścia.
  • NIE używaj allow-discrete w skrócie transition – spowoduje to, że starsze przeglądarki będą ignorować całą deklarację transition. Z wyjątkiem przypadków, w których jest to pożądane, używaj oddzielnej deklaracji transition-behavior: allow-discrete.
  • OBOWIĄZKOWO: w przypadku animacji wejścia używaj @starting-style. Przeglądarki pomijają przejścia przy pierwszej aktualizacji stylu elementu (początkowe renderowanie lub zmiana display: none), chyba że podano tę wartość.
  • TAK: uwzględnij overlay na liście transition, jeśli animujesz elementy najwyższej warstwy, takie jak <dialog> lub popover, aby mieć pewność, że pozostaną w najwyższej warstwie podczas animacji zakończenia.
  • DO: respektuj preferencje użytkowników dotyczące ograniczenia ruchu, używając zapytania o media prefers-reduced-motion.
  • NIE: polegaj na @starting-style w przypadku animacji wyjścia – definiuje on tylko punkt początkowy przejścia wejściowego. Animacje wyjścia są definiowane przez przejście do stanu ukrytego.

Strategie zastępcze

Kreacje zastępcze i obsługa właściwości transition-behavior w przeglądarkach

Firefox 129 i nowsze wersje analizują transition-behavior: allow-discrete (CSS.supports('transition-behavior', 'allow-discrete') zwraca true) bez faktycznego przejścia właściwości display (błąd Firefoksa 1882408), co powoduje, że elementy natychmiast znikają po wyjściu.

Aby niezawodnie wykrywać obsługę dyskretnych przejść display, sprawdź, czy obliczona wartość display elementu tymczasowego pozostaje widoczna po przejściu do none, zamiast polegać wyłącznie na 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;
}

Zastępcze wyjście, gdy przejścia dyskretne display nie są obsługiwane

Animacje wejściowe wykorzystujące @starting-style działają we wszystkich nowoczesnych przeglądarkach bez JavaScriptu. Gdy dyskretne przejścia display nie są obsługiwane (!canTransitionDisplay()), ustawienie hidden powoduje natychmiastowe zastosowanie display: none i pominięcie animacji wyjścia (zarówno podczas ukrywania, jak i przed element.remove()). Oddziel wizualny stan wyjścia ([data-closing]) od display: none ([hidden]), aby opacity i translate zakończyły animację przed ukryciem lub usunięciem elementu. Zastąp reguły .card[hidden] z kroku 1 (w tym zastąpienie prefers-reduced-motion) tymi regułami:

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