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:
- Define el estado visible: Establece los valores de propiedad finales (p.ej.,
opacity: 1) en la clase base. - Define el estado inicial de entrada: Usa
@starting-stylepara especificar los valores de los que se debe realizar la transición desde cuando el elemento se vuelve visible. - Habilita las transiciones discretas: Incluye
displayen la propiedadtransitiony usatransition-behavior: allow-discrete. - 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-stylecomo 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-discretecuando realices la transición dedisplay. Sin él, el elemento desaparecerá de inmediato durante la salida. - NO uses
allow-discreteen la abreviaturatransition, ya que hará que los navegadores más antiguos ignoren toda la declaracióntransition. Excepto en los casos de uso en los que sea conveniente, usa una declaracióntransition-behavior: allow-discreteindependiente. - OBLIGATORIO: Usa
@starting-stylepara las animaciones de entrada. Los navegadores omiten las transiciones en la primera actualización de estilo de un elemento (renderización inicial o cambio dedisplay: none), a menos que se proporcione esto. - SÍ: Incluye
overlayen la listatransitionsi animas elementos de la capa superior, como<dialog>opopover, 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-stylepara 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();
}