Cómo animar la entrada y la salida de elementos

En el pasado, las transiciones de CSS no podían animar elementos cuando se agregaban por primera vez al DOM o cuando su propiedad display cambiaba de none. La regla-at @starting-style y transition-behavior: allow-discrete proporcionan una forma declarativa de crear animaciones de entrada y salida fluidas.

Implementación

1. Cómo animar los botones de activación de display: none

Para animar un elemento cuando se activa o desactiva su visibilidad a través de un atributo (p.ej., hidden con display: none), haz lo siguiente:

  1. Define el estado visible: Establece los valores de propiedad finales (p.ej., opacity: 1) en la clase base.
  2. Define el estado inicial de entrada: Usa @starting-style para especificar los valores de los que se debe realizar la transición desde cuando el elemento se vuelve visible.
  3. Habilita las transiciones discretas: Incluye display en la propiedad transition y usa transition-behavior: allow-discrete.
  4. Define el estado de salida: Establece los valores de destino en el 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. Cómo animar la inserción y eliminación del DOM

Para los elementos que se agregan con appendChild() o se quitan con remove(), haz lo siguiente:

  • Entrada: Usa @starting-style como se muestra arriba. El navegador detectará automáticamente el cambio de estilo de "nada" a los estilos iniciales del elemento y activará la transición desde los valores de @starting-style.
  • Eliminación: Como element.remove() es instantáneo y no activa una transición de CSS por sí solo, primero debes activar la transición de salida y esperar a que finalice antes de quitar el nodo del 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();

Restricciones y accesibilidad

  • OBLIGATORIO: Usa transition-behavior: allow-discrete cuando realices la transición de display. Sin él, el elemento desaparecerá de inmediato durante la salida.
  • NO uses allow-discrete en la abreviatura transition, ya que hará que los navegadores más antiguos ignoren toda la declaración transition. Excepto en los casos de uso en los que sea conveniente, usa una declaración transition-behavior: allow-discrete independiente.
  • OBLIGATORIO: Usa @starting-style para las animaciones de entrada. Los navegadores omiten las transiciones en la primera actualización de estilo de un elemento (renderización inicial o cambio de display: none), a menos que se proporcione esto.
  • SÍ: Incluye overlay en la lista transition si animas elementos de la capa superior, como <dialog> o popover, para asegurarte de que permanezcan en la capa superior durante la animación de salida.
  • SÍ: Respeta las preferencias del usuario de reducir el movimiento con la consulta de medios prefers-reduced-motion.
  • NO uses @starting-style para las animaciones de salida, ya que solo define el punto de partida de una transición de entrada. Las animaciones de salida se definen por la transición al estado oculto.

Estrategias de resguardo

Resguardos y compatibilidad de navegadores con transition-behavior

Firefox 129 y versiones posteriores analizan transition-behavior: allow-discrete (CSS.supports('transition-behavior', 'allow-discrete') devuelve true) sin realizar la transición de la propiedad display (error de Firefox 1882408), lo que hace que los elementos desaparezcan de inmediato al salir.

Para detectar de forma confiable la compatibilidad con transiciones discretas de display, prueba si el display calculado de un elemento temporal permanece visible cuando se realiza la transición a none, en lugar de depender únicamente 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;
}

Resguardo de salida cuando no se admiten transiciones display discretas

Las animaciones de entrada que usan @starting-style funcionan en todos los navegadores actualizados sin JavaScript. Cuando no se admiten las transiciones discretas de display (!canTransitionDisplay()), configurar hidden aplica display: none de inmediato y omite la animación de salida (tanto cuando se oculta como antes de element.remove()). Separa el estado de salida visual ([data-closing]) de display: none ([hidden]) para que opacity y translate terminen de animarse antes de ocultar o quitar el elemento. Reemplaza las reglas de .card[hidden] del paso 1 (incluida la anulación de prefers-reduced-motion) por lo siguiente:

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