Animar a entrada e saída de elementos

No passado, as transições CSS não podiam animar elementos quando eram adicionados pela primeira vez ao DOM ou quando a propriedade display mudava de none. A regra @ @starting-style e transition-behavior: allow-discrete oferecem uma maneira declarativa de criar animações de entrada e saída suaves.

Implementação

1. Como animar botões de ativação do display: none

Para animar um elemento ao alternar a visibilidade dele usando um atributo (por exemplo, hidden com display: none):

  1. Defina o estado visível: defina os valores finais da propriedade (por exemplo, opacity: 1) na classe de base.
  2. Defina o estado inicial da entrada: use @starting-style para especificar os valores de transição de quando o elemento ficar visível.
  3. Ativar transições discretas: inclua display na propriedade transition e use transition-behavior: allow-discrete.
  4. Defina o estado de saída: defina os valores de destino no atributo 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. Como animar a inserção e remoção do DOM

Para elementos adicionados por appendChild() ou removidos por remove():

  • Entrada: use @starting-style, conforme mostrado acima. O navegador vai detectar automaticamente a mudança de estilo de "nada" para os estilos iniciais do elemento e acionar a transição dos valores @starting-style.
  • Remoção: como element.remove() é instantâneo e não aciona uma transição de CSS por conta própria, é necessário acionar a transição de saída primeiro e esperar que ela termine antes de remover o nó do 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();

Restrições e acessibilidade

  • OBRIGATÓRIO: use transition-behavior: allow-discrete ao fazer a transição de display. Sem ele, o elemento vai desaparecer instantaneamente durante a saída.
  • NÃO use allow-discrete na abreviação transition. Isso fará com que navegadores mais antigos ignorem toda a declaração transition. Exceto em casos de uso em que isso é desejável, use uma declaração transition-behavior: allow-discrete separada.
  • OBRIGATÓRIO: use @starting-style para animações de entrada. Os navegadores pulam as transições na primeira atualização de estilo de um elemento (renderização inicial ou mudança de display: none), a menos que isso seja fornecido.
  • RECOMENDADO: inclua overlay na lista transition ao animar elementos da camada superior, como <dialog> ou popover, para garantir que eles permaneçam na camada superior durante a animação de saída.
  • RECOMENDADO: respeite as preferências do usuário para reduzir o movimento usando a consulta de mídia prefers-reduced-motion.
  • NÃO: use @starting-style para animações de saída. Ele define apenas o ponto de partida de uma transição de entrada. As animações de saída são definidas pela transição para o estado oculto.

Estratégias de fallback

Alternativas e suporte do navegador para transition-behavior

O Firefox 129+ analisa transition-behavior: allow-discrete (CSS.supports('transition-behavior', 'allow-discrete') retorna true) sem realmente fazer a transição da propriedade display (bug 1882408 do Firefox), fazendo com que os elementos desapareçam imediatamente ao sair.

Para detectar de forma confiável o suporte à transição discreta de display, verifique se o display calculado de um elemento temporário permanece visível quando é feita a transição para none, em vez de depender apenas de 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;
}

Fallback de saída quando as transições discretas de display não são compatíveis

As animações de entrada que usam @starting-style funcionam em todos os navegadores modernos sem JavaScript. Quando as transições discretas de display não são compatíveis (!canTransitionDisplay()), definir hidden aplica display: none imediatamente e pula a animação de saída (ao ocultar e antes de element.remove()). Separe o estado de saída visual ([data-closing]) de display: none ([hidden]) para que opacity e translate terminem a animação antes de ocultar ou remover o elemento. Substitua as regras .card[hidden] da etapa 1 (incluindo a substituição de prefers-reduced-motion) por:

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