El patrón de revelado al entrar
El efecto más pedido resuelto entero y sin trampas: el estado por defecto seguro, el escalonado de una lista y qué hace al volver a subir.
“Que aparezca al hacer scroll” es la petición más frecuente de toda la animación web, y la que más veces está mal resuelta. La versión con IntersectionObserver que circula desde 2017 deja contenido invisible si el JavaScript falla; la versión con view() copiada de un tutorial hace lo mismo si el navegador no implementa timelines. Vamos a construirla de forma que el modo de fallo sea siempre “no hay animación”, nunca “no hay contenido”.
- Escribir un revelado cuyo estado por defecto sea el contenido visible.
- Escalonar una lista sin generar una clase por elemento.
- Decidir qué hace la animación cuando el usuario vuelve a subir.
- Integrar
prefers-reduced-motionsin desactivar el efecto entero.
El estado por defecto es el estado final
La regla que hay que interiorizar antes de escribir la primera línea: el CSS base describe el contenido tal y como debe verse cuando no hay animación. La animación es una capa que se añade encima, dentro de un @supports, y que empieza por el estado invisible y termina en el estado base.
Esto invierte el orden en el que casi todo el mundo escribe. La versión habitual es:
/* MAL: el contenido nace invisible */
.revelar {
opacity: 0;
transform: translateY(2rem);
animation: aparecer linear both;
animation-timeline: view();
animation-range: entry;
}
En Chromium y Safari funciona. En Firefox, la animación no existe, animation-timeline se ignora, el @keyframes no se aplica, y lo que queda es la regla base: opacidad cero. El contenido es invisible y no hay forma de recuperarlo scrolleando. Es un fallo de categoría “roto”, no de categoría “cosmético”, y por eso el patrón correcto es este otro:
@keyframes revelar {
from {
opacity: 0;
transform: translateY(2rem);
}
/* Sin bloque 'to': el estado final es el estilo base del elemento */
}
.revelar {
/* Estado base = estado final. Sin animacion, se ve asi. */
opacity: 1;
transform: none;
}
@supports (animation-timeline: view()) {
.revelar {
animation: revelar linear both;
animation-timeline: view();
animation-range: entry 0% entry 80%;
}
}
Dos detalles del @keyframes que no son casualidad. El primero: solo hay bloque from. Cuando un keyframe no especifica una propiedad, el valor que se usa es el que tendría el elemento sin la animación, así que omitir el to es la forma de decir “termina en tu estilo normal” sin duplicarlo. Si mañana alguien cambia la opacidad base a 0.9, la animación sigue terminando donde debe.
El segundo: entry 0% entry 80%. Terminar en el ochenta por ciento en lugar del cien deja al elemento completamente formado un poco antes de acabar de entrar, y eso se percibe como más ágil. Un revelado que acaba exactamente cuando el elemento termina de entrar se siente perezoso, porque el último tramo del movimiento coincide con el momento en que el usuario ya está mirando otra cosa.
Escalonar una lista sin clases por elemento
El escalonado —que los elementos de una lista aparezcan uno detrás de otro— es, con timelines de vista, más fácil de lo que parece, porque ya está resuelto por la geometría: cada elemento tiene su propia timeline y entra cuando le toca. No hace falta ningún retardo.
Lo que sí hace falta es cuidado cuando los elementos están en una rejilla y varios entran a la vez. Ahí el escalonado tiene que venir de otro sitio, y la opción limpia es una custom property calculada desde el índice, combinada con el inset:
.rejilla {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 1.5rem;
}
@keyframes revelar {
from { opacity: 0; transform: translateY(1.5rem) scale(0.98); }
}
.rejilla > * {
opacity: 1;
transform: none;
}
@supports (animation-timeline: view()) {
.rejilla > * {
animation: revelar linear both;
animation-timeline: view();
animation-range: entry 0% entry 70%;
}
/* Escalonado por columna: cada una arranca un poco mas tarde */
.rejilla > :nth-child(3n + 2) { view-timeline-inset: auto 6%; }
.rejilla > :nth-child(3n + 3) { view-timeline-inset: auto 12%; }
}
El truco está en que un inset mayor retrasa el arranque respecto a la pantalla, y como las tres columnas comparten fila y por tanto geometría, retrasar el inset produce exactamente el efecto de cascada. No hay retardos ni índices en el marcado, y funciona con cualquier número de elementos.
La alternativa —usar animation-delay— no funciona aquí, porque sobre una timeline de progreso el retardo no significa tiempo. Es uno de los sitios donde el instinto de la animación clásica lleva directo a la pared.
Qué pasa al subir
Una animación dirigida por scroll es reversible por naturaleza: si el usuario sube, el progreso baja y la animación se deshace. El elemento vuelve a desvanecerse. Y eso es, casi siempre, indeseable: el contenido ya se ha leído, revelarlo otra vez lo convierte en un parpadeo.
No hay ninguna forma de decir “que no se revierta” en CSS puro, porque la reversibilidad es la definición del modelo. Hay tres respuestas honestas, y elegir bien depende del contenido:
Aceptar la reversión y hacerla discreta. Si la animación es sutil —una opacidad del 0.6 al 1, un desplazamiento de medio rem— la reversión no molesta y hasta se lee como profundidad. Es la opción que mejor envejece.
Acotar el rango a la parte alta de la entrada. Con animation-range: entry 0% entry 40%, el elemento está completamente revelado durante el ochenta por ciento de su recorrido visible, así que la reversión solo ocurre cuando el usuario sube lo bastante como para sacarlo casi de pantalla, y en ese punto ya no la ve.
Aceptar que necesitas JavaScript. Si el requisito real es “revelar una vez y no volver a tocarlo”, eso es una máquina de estados, no una interpolación, y la herramienta correcta es IntersectionObserver con unobserve() al primer cruce. No hay que forzar las timelines a hacer algo que por diseño no hacen.
Voy a decir lo que el resto de tutoriales sobre este patrón no dice: en la mayoría de los sitios, este efecto no debería existir. El razonamiento es de primeros principios. Una animación de entrada retrasa la percepción del contenido: durante los milisegundos en que el texto está a opacidad parcial, el usuario no puede leerlo. Ese coste se paga en cada elemento, en cada visita, y crece con la longitud de la página. A cambio, el beneficio es puramente estético y se agota en la primera exposición: nadie ha vuelto a un sitio por cómo aparecían los párrafos. En un artículo largo, un revelado por bloque convierte la lectura en una sucesión de esperas mínimas que el usuario no sabe nombrar pero que percibe como lentitud. Hay tres sitios donde sí compensa y conviene reconocerlos: cuando el revelado dirige la atención a un elemento que de otro modo pasaría desapercibido; cuando comunica estructura, agrupando visualmente elementos relacionados por su entrada conjunta; y cuando la página es una presentación, no un documento, y el ritmo es el contenido. Fuera de esos tres casos, la mejor animación de entrada es ninguna, y el hecho de que ahora se pueda hacer con tres líneas de CSS en lugar de cuarenta de JavaScript no cambia el análisis: solo abarata el error. Si vas a implementarlo, al menos hazlo con el contenido visible por defecto, porque entonces el peor caso es que alguien lo vea sin animación, que es exactamente el caso bueno.
La capa de movimiento reducido
Queda la última pieza, y la forma correcta no es apagar la animación entera sino sustituirla por una versión sin desplazamiento. Quien pide reducir el movimiento no pide que no cambie nada: pide que no haya traslaciones ni escalados grandes, que son los que provocan malestar vestibular. Una transición de opacidad no mueve nada y es perfectamente aceptable.
@keyframes revelar-suave {
from { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
@supports (animation-timeline: view()) {
.revelar,
.rejilla > * {
animation-name: revelar-suave;
view-timeline-inset: auto;
}
}
}
Solo se cambia el nombre de la animación y se anula el escalonado. El resto de la configuración —la timeline, el rango, el fill-mode— se hereda de la regla anterior. Es la forma más barata de mantener dos versiones sin duplicar nada, y la que menos posibilidades tiene de desincronizarse cuando alguien ajuste el rango dentro de seis meses.