wandres.dev
ANIMAR LA ENTRADA · @starting-style y transition-behavior

El problema del estado antes de existir

Por qué durante veinte años no hubo forma declarativa de animar la aparición de un elemento, y qué tenía que resolver exactamente la solución.

⏱ 16 min

Animar la desaparición de algo siempre fue fácil: el elemento está ahí, cambias su estilo, transiciona. Animar la aparición fue imposible en CSS puro durante toda la historia del lenguaje, y el motivo no era una omisión de la especificación sino un problema conceptual real: una transición necesita dos valores y un elemento que acaba de nacer solo tiene uno. Esta lección plantea el problema con precisión antes de ver la solución, porque entenderlo explica también por qué la solución tiene la forma que tiene.

🎯 Al terminar esta lección sabrás
  • Enunciar con exactitud por qué un elemento nuevo no puede transicionar.
  • Distinguir los tres casos de aparición y ver por qué comparten causa.
  • Evaluar los tres remedios anteriores a 2023 y sus defectos concretos.
  • Identificar qué requisitos tenía que cumplir una solución declarativa.

El problema, con precisión

Una transición arranca cuando, en un evento de cambio de estilo, el valor computado de una propiedad difiere del que tenía en el evento anterior. Ese “el que tenía en el evento anterior” es lo que la especificación llama el estilo anterior al cambio, y es exactamente lo que un elemento recién creado no tiene: no hubo evento anterior.

Fíjate en que esto no es una limitación de implementación. Aunque el motor quisiera animar, no sabría desde dónde. Si insertas un elemento con opacity: 1, ¿desde qué opacidad debería aparecer? ¿0? ¿0.5? La respuesta no está en ningún sitio del documento, porque el elemento no existía y por tanto no tenía estilo.

Hay tres situaciones distintas que caen bajo el mismo problema, y merece la pena verlas juntas porque en el código parecen tres bugs diferentes:

El elemento se inserta en el DOM. El caso más obvio. Nunca tuvo estilo computado.

El elemento pasa de display: none a visible. Aquí sí tenía estilo computado, pero no se estaba renderizando, y las transiciones no se ejecutan sobre elementos que no se renderizan. Desde el punto de vista del sistema de animación, es equivalente a no existir.

El elemento entra en la capa superior. Un dialog que se abre con showModal(), o un elemento con popover que se muestra. Pasa a renderizarse en un contexto nuevo, y además el navegador le aplica estilos propios del agente de usuario que incluyen un cambio de display.

Los tres son el mismo problema con tres disfraces: falta un punto de partida.

Los tres remedios anteriores y qué fallaba en cada uno

Remedio uno: forzar un evento de cambio de estilo desde JavaScript. Es el que estudiamos en el nivel de transiciones: insertas con el estado inicial, fuerzas el cálculo, aplicas el estado final. Funciona y tiene tres defectos. Obliga a que haya JavaScript en un problema que es puramente de presentación. Provoca un recálculo síncrono, que afecta a todo lo que estuviera sucio. Y no resuelve el caso de display: none sin una secuencia adicional.

Remedio dos: usar @keyframes en lugar de una transición. Una animación CSS se puede aplicar a un elemento nuevo, porque no compara dos estados: describe la trayectoria completa. Este remedio funciona bien para la entrada:

.aviso {
  animation: entrar 240ms cubic-bezier(0.2, 0, 0, 1) both;
}
@keyframes entrar {
  from { opacity: 0; translate: 0 -8px; }
}

Su defecto no está en la entrada sino en todo lo demás. Una animación tiene estado propio: hay que gestionar su final, no se interrumpe con la elegancia de una transición, y el par entrada y salida deja de ser simétrico —la entrada es una animación y la salida una transición o una segunda animación—. En cuanto el elemento puede aparecer y desaparecer varias veces, la asimetría produce bugs de estado.

Remedio tres: mantener el elemento siempre en el DOM y renderizado. Nunca lo insertas ni le pones display: none; lo dejas con opacity: 0 y visibility: hidden. Como siempre existió, siempre hay estilo anterior al cambio y las transiciones funcionan en los dos sentidos.

Es el remedio más limpio de los tres y sigue siendo válido para muchos casos. Su defecto es de coste: el elemento ocupa sitio en el árbol, participa en el layout —visibility: hidden reserva espacio— y su contenido se pinta y se rasteriza aunque no se vea. Con un diálogo da igual; con doscientas filas de una tabla que tienen un menú contextual cada una, no.

Además, visibility: hidden no lo saca del flujo, así que para un elemento que no debe ocupar espacio hay que recurrir a position: absolute, y ahí se acaba la simplicidad.

Los tres remedios comparten un defecto que nadie menciona: rompen la accesibilidad de formas distintas

La comparación entre los tres remedios se suele hacer en términos de elegancia y de rendimiento, y se deja fuera la dimensión donde más se diferencian. El remedio de mantener el elemento siempre presente lo deja en el árbol de accesibilidad, así que un lector de pantalla lo anuncia y el tabulador lo enfoca aunque sea invisible para todos los demás. Es el bug de accesibilidad más común en menús y diálogos hechos a mano: el usuario de teclado tabula y el foco desaparece dentro de un panel cerrado. Ponerle visibility: hidden lo arregla —esa propiedad sí lo saca del árbol de accesibilidad, a diferencia de opacity: 0—, pero entonces hay que acordarse de que visibility es discreta y de que su interpolación especial es lo único que hace que el patrón funcione. El remedio de las animaciones con @keyframes rompe otra cosa: como la salida hay que gestionarla aparte, el elemento se elimina del DOM cuando la animación acaba, y si el foco estaba dentro, salta al body sin avisar; el usuario de teclado pierde el sitio. Y el remedio de forzar el estilo desde JavaScript hereda los dos problemas según cómo esté implementado, además de fallar por completo si el JavaScript no llega a ejecutarse. La conclusión que importa es que este problema nunca fue solo estético: la falta de una forma declarativa de animar la aparición obligaba a gestionar la presencia del elemento desde código, y gestionarla desde código es donde se rompe la accesibilidad. Una solución declarativa que permita que el elemento entre y salga del árbol con su animación resuelve las dos cosas a la vez, y ese es el criterio con el que hay que juzgar lo que viene en las lecciones siguientes.

Qué tenía que cumplir la solución

De todo lo anterior salen cuatro requisitos, y las capacidades que veremos en el resto del nivel los cumplen los cuatro. Merece la pena tenerlos claros porque explican decisiones de diseño que de otro modo parecen arbitrarias.

Uno: declarar un estado de partida sin que ese estado se aplique nunca fuera del momento de aparición. No basta con poner el estilo inicial en la regla base, porque entonces sería el estado permanente. Hace falta una forma de decir “esto es el valor antes de existir”, que solo se use en ese instante. Es lo que hace @starting-style.

Dos: permitir que display participe en la transición. Si el elemento tiene que pasar de display: none a visible, y display es una propiedad discreta que salta, el elemento aparecería de golpe en el instante equivocado. Hace falta poder decir que las propiedades discretas también participan, con una regla de salto que mantenga el elemento visible mientras dura la animación. Es lo que hace transition-behavior: allow-discrete.

Tres: que funcione también para la capa superior. Un dialog modal o un popover se promocionan a la capa superior, y salir de ella es también un cambio discreto que ocurre inmediatamente. Sin control sobre ese momento, la animación de salida se ejecutaría con el elemento ya fuera de la capa superior, es decir, tapado por lo que tuviera encima. Es lo que resuelve la propiedad overlay, con la limitación de soporte que veremos.

Cuatro: que no exija JavaScript. Porque el problema es de presentación, y porque cualquier solución que lo exija falla cuando el código no se ejecuta.

Los tres primeros requisitos corresponden a tres capacidades distintas que se diseñaron juntas y que se usan casi siempre juntas. Esa es la razón de que la documentación las presente como un bloque: por separado ninguna resuelve el problema completo.

⚔️ Reproduce el problema en sus tres formas
  1. Inserta un elemento con una transición de opacidad y confirma con getAnimations() que no se crea ninguna transición.
  2. Haz lo mismo con un elemento que pasa de display: none a block y comprueba que forzar el estilo tampoco basta.
  3. Abre un dialog con showModal() que tenga una transición declarada y observa qué ocurre.
  4. Implementa el remedio de mantener el elemento siempre presente y comprueba con el tabulador si el foco entra en él cuando está oculto, primero con opacity: 0 y después añadiendo visibility: hidden.