wandres.dev
ONTOLOGÍA · El mapa de la animación web

Dónde se solapan las familias y quién gana

El orden de la cascada para valores animados, el orden de composición entre animaciones, y las tres colisiones que producen bugs difíciles de leer.

⏱ 17 min

Las cuatro familias no ocupan territorios disjuntos: se solapan a propósito, porque hay problemas que se pueden resolver desde varias. El solape es sano mientras solo una familia gobierne cada propiedad de cada elemento. Cuando dos gobiernan la misma, entras en un territorio con reglas exactas y poco conocidas, donde una transición puede vencer a una declaración !important y una animación creada desde JavaScript puede tapar una escrita en CSS. Esta lección expone esas reglas antes de que te muerdan.

🎯 Al terminar esta lección sabrás
  • Situar los valores animados y transicionados en el orden de la cascada.
  • Predecir qué animación gana cuando varias afectan a la misma propiedad.
  • Reconocer las tres colisiones frecuentes entre familias y su síntoma.
  • Elegir la propiedad correcta para evitar colisiones estructurales.

La cascada tiene ocho niveles, no cuatro

El orden de la cascada que casi todo el mundo conoce —agente de usuario, usuario, autor, y !important invirtiendo los tres— está incompleto. Faltan dos niveles, y son justo los que importan aquí. De menor a mayor precedencia:

Nivel Origen
1 Declaraciones normales del agente de usuario
2 Declaraciones normales del usuario
3 Declaraciones normales del autor
4 Declaraciones de animación
5 Declaraciones importantes del autor
6 Declaraciones importantes del usuario
7 Declaraciones importantes del agente de usuario
8 Declaraciones de transición

Léelo despacio, porque hay dos consecuencias fuertes.

Una transición en curso vence a !important. No hay excepción y no es un bug. Mientras una propiedad está transicionando, el valor interpolado se aplica por encima de cualquier declaración, incluidas las que llevan !important del propio agente de usuario. En el instante en que la transición termina, ese nivel desaparece y el valor vuelve a decidirse por la cascada normal, donde !important gana otra vez. El síntoma cuando esto te pilla es característico: el elemento se mueve durante la duración y después salta al valor “correcto”.

Una animación CSS pierde contra !important. Esto es lo contrario y también sorprende. Si alguien declara opacity: 1 !important en una hoja de estilos, tus @keyframes que animan la opacidad no se verán, porque el nivel 5 está por encima del 4. Es el motivo por el que las hojas de reinicio agresivas rompen animaciones de terceros de una forma que parece imposible de diagnosticar: el panel de estilos muestra la regla de la animación como aplicada, y aun así no ocurre nada.

/* La animacion no se vera: !important esta por encima del nivel de animacion. */
.aviso { opacity: 1 !important; }

@keyframes latido { 50% { opacity: 0.4; } }
.aviso { animation: latido 1s infinite; }

Este orden es también la razón profunda de por qué una animación sin fill provoca un salto al terminar. No es que “se borre el estilo”: es que el nivel 4 deja de existir para esa propiedad y el valor cae al nivel 3, que nunca cambió.

El orden de composición entre animaciones

Dentro del sistema de animación puede haber varias animaciones afectando a la misma propiedad del mismo elemento a la vez. El modelo las apila en lo que la especificación llama la pila de efectos y las evalúa en un orden definido: la última en el orden de composición es la que decide el valor final. Ese orden se establece primero por clase de animación y, dentro de cada clase, por antigüedad.

Las clases, de menor a mayor prioridad, son tres: transiciones CSS, animaciones CSS y todas las demás, que es donde caen las creadas con element.animate(). La regla práctica se resume en una línea: lo imperativo gana a lo declarativo.

// La caja tiene en CSS: animation: latido 1s infinite;
// Esta animacion WAAPI sobre la misma propiedad se impone.
caja.animate([{ opacity: 1 }, { opacity: 0 }], {
  duration: 600,
  fill: 'forwards',
});

Que lo imperativo gane es una decisión de diseño defendible: si has escrito código para animar algo concreto en este instante, es más probable que expreses la intención inmediata que la hoja de estilos, que expresa la intención por defecto. Pero convierte cualquier mezcla en frágil, porque el ganador depende de un orden que no se ve en ninguna parte.

Hay una salida cuando de verdad quieres que dos animaciones convivan sobre la misma propiedad, y es no dejar que compitan: composite: 'add' hace que la segunda se sume a la primera en lugar de reemplazarla. Sirve para superponer, por ejemplo, un temblor sobre un desplazamiento en curso sin que uno anule al otro.

elem.animate([{ translate: '0 0' }, { translate: '0 -40px' }],
  { duration: 500, easing: 'ease-out', fill: 'forwards' });

// Se suma al desplazamiento anterior en lugar de sustituirlo.
elem.animate([{ translate: '-3px 0' }, { translate: '3px 0' }],
  { duration: 60, iterations: 6, direction: 'alternate', composite: 'add' });
La colisión que no se ve: dos familias sobre la misma propiedad compuesta

Las colisiones entre familias que más tiempo cuestan no son las de dos animaciones sobre opacity, que se detectan en un minuto. Son las de dos animaciones sobre la misma propiedad compuesta pero con intenciones distintas, y el caso canónico es transform. Si una regla CSS anima transform: translateY(...) para desplazar un panel y otra regla anima transform: scale(...) para el efecto de pulsación, no se combinan: transform es una propiedad cuyo valor es una lista completa, así que la segunda no añade una escala, sustituye la lista entera y el desplazamiento desaparece. La solución no es reordenar ni usar !important, es dejar de compartir propiedad: translate, rotate y scale existen como propiedades independientes precisamente para esto, y desde 2022 están en los cuatro motores. Cada una tiene su propio valor computado, su propia transición y su propia entrada en la pila de efectos, así que dos animaciones que toquen translate y scale respectivamente conviven sin saber la una de la otra. El orden en que se aplican está fijado por la especificación —primero translate, luego rotate, luego scale, y todo eso antes de lo que quede en transform—, así que el resultado es determinista y no depende de quién escribió antes. La regla que se deriva de esto es sencilla y evita una familia entera de bugs: reserva transform para transformaciones que se declaran de una pieza, y usa las propiedades individuales en cuanto haya más de un actor. Y si necesitas dos desplazamientos independientes sobre el mismo elemento, la respuesta correcta no es apilarlos en translate, es meter un elemento envolvente y dar a cada actor el suyo.

Las tres colisiones frecuentes

Transición y animación sobre la misma propiedad. Ocurre cuando declaras transition: all en un componente y más tarde le añades una animación con @keyframes. Como las transiciones ocupan la posición más baja del orden de composición, la animación gana mientras dura, pero la transición sigue existiendo y vuelve a tomar el control en cuanto la animación termina. El síntoma es un movimiento residual después de que la animación haya acabado, con la curva y la duración de la transición, que nadie recuerda haber pedido.

Clase con transición más animación WAAPI efímera. Es el patrón de “añado la clase y además hago un animate() para el detalle”. Mientras la animación imperativa vive, tapa a la transición. Cuando se cancela, el valor cae a la transición, que arranca desde donde estaba. El resultado suele ser aceptable por casualidad, pero deja de serlo en cuanto las duraciones no coinciden.

Estilo en línea escrito por commitStyles() que nadie limpia. commitStyles() escribe el valor final de la animación en el atributo style del elemento. Eso es estilo de autor del nivel 3, y por tanto persiste para siempre y gana a cualquier regla de la hoja que no sea !important. El síntoma es un componente que la segunda vez que se usa arranca desde el estado final de la vez anterior. La disciplina correcta es que quien escribe estilo en línea sea también quien lo borra, con el.style.removeProperty(...).

Detectar cualquiera de las tres es rápido si tienes el modelo del nivel de ontología en la cabeza: enumera el.getAnimations() y mira cuántas hay y de qué clase.

// Cuantas animaciones compiten y de que tipo.
console.table(el.getAnimations().map(a => ({
  clase: a.constructor.name,
  propiedades: Object.keys(a.effect.getKeyframes()[0] ?? {}),
  estado: a.playState,
})));

La regla que evita el problema en origen

Todas las colisiones anteriores comparten una causa: más de un dueño para el mismo valor. La disciplina que las elimina no es un truco de sintaxis, es una convención de propiedad, y se enuncia en una frase: cada propiedad animable de cada elemento tiene un único dueño, y ese dueño es una familia concreta.

En la práctica esto significa tres cosas. Que si un componente anima opacity desde CSS, nadie fuera del componente la toca. Que si necesitas dos movimientos simultáneos e independientes, los repartes entre translate, scale, rotate o entre elementos anidados, no los apilas. Y que las animaciones efímeras creadas desde JavaScript se cancelan explícitamente al terminar, para que el valor vuelva a su dueño.

Es exactamente la misma disciplina que se aplica al estado de una aplicación, y por la misma razón: un valor con dos fuentes de verdad no tiene ninguna.

⚔️ Provoca las colisiones a propósito
  1. Declara .caja { opacity: 1 !important; } y una animación de opacidad con @keyframes. Comprueba que no se ve y localiza el nivel de la cascada responsable.
  2. Anima transform: translateY desde una clase y transform: scale desde otra sobre el mismo elemento. Reescríbelo con translate y scale y comprueba que ahora conviven.
  3. Lanza una animación WAAPI de opacidad sobre un elemento que ya tiene transition: opacity 1s. Cancélala a mitad y describe qué curva sigue el valor a partir de ese instante.