Casi todo el mal rendimiento que se atribuye a las animaciones no viene de animar demasiado. Viene de animar lo que no se debe, de disparar cuando no toca y de no haberlo mirado nunca en un teléfono de verdad.
Esto es lo que aplicamos en producción, con las razones. No hay nada aquí que no se pueda comprobar en veinte minutos con las herramientas del navegador.
1 · Anima transform y opacity. Lo demás, casi nunca
El navegador pinta un fotograma en cinco fases: estilo, layout, pintado, composición y presentación. Cada propiedad entra por una fase distinta, y ahí está toda la diferencia.
| Si animas… | Dispara | Coste |
|---|---|---|
width, height, top, left, margin, padding | Layout → pintado → composición | Alto, y crece con el número de nodos |
background-color, box-shadow, border-radius | Pintado → composición | Medio, sube con el área |
transform, opacity, filter | Sólo composición | Bajo, lo lleva la GPU |
Un left: 0 → 400px obliga al navegador a recalcular la posición de todo lo que pueda haberse
desplazado, en cada fotograma. Un x: 400 mueve una textura ya rasterizada. El resultado en
pantalla es idéntico y el coste no se parece.
GSAP ayuda aquí más de lo que parece: x, y, scale y rotation se
escriben todas al mismo transform, con orden estable y sin que tengas que concatenar cadenas.
Eso evita el error clásico de dos tweens pisándose la matriz.
2 · Mide desde el layout, no desde el tween
Hay animaciones que no se pueden hacer sólo con transform: una tarjeta que crece de miniatura
a pantalla completa cambia de tamaño de verdad. Para eso está FLIP, que es la técnica de
medir el estado inicial (First), aplicar el final (Last), calcular la diferencia
(Invert) y animar sólo esa diferencia con transform (Play).
El resultado es una animación que parece cambiar de tamaño mientras el navegador sólo compone. Un cambio de caja se convierte en un cambio de matriz.
3 · Un ScrollTrigger por gesto, no uno por elemento
Es tentador poner un disparador a cada tarjeta de una rejilla de cuarenta. Cada uno mantiene su propio cálculo de posiciones y todos se recalculan cuando el documento cambia de alto.
Un solo disparador sobre el contenedor, con stagger hacia dentro, hace lo mismo con una
cuadragésima parte de la contabilidad:
- Uno por sección, no uno por hijo.
once: trueen todo lo que sea una entrada. Una animación que rebobina al subir es una animación que se recalcula el doble y que, además, se lee peor.invalidateOnRefresh: trueen lo que dependa de medidas, para que el giro de pantalla no deje al disparador con el mapa viejo.
4 · Un refresh() cuando el documento acabe de crecer
Este es el fallo que más veces hemos visto en revisiones ajenas, y es invisible en escritorio.
document.fonts.ready espera a las fuentes, no a las imágenes. Si una foto sin
width/height llega tarde, el documento cambia de altura después de que los
disparadores hayan anotado sus posiciones. A partir de ahí todos apuntan a coordenadas que ya no
existen, y el síntoma es siempre el mismo: una animación del final de la página que se queda a
medias y que «se arregla sola» si redimensionas la ventana.
Dos medidas, las dos baratas:
- Atributos
widthyheighten todas las imágenes, para que el hueco esté reservado desde el primer pintado. - Un
ScrollTrigger.refresh()en el eventoload, que es el redimensionado que el navegador nunca te va a dar.
5 · Lo que no está en pantalla no debe estar decodificando
Un vídeo en bucle a mitad de una página de nueve mil píxeles sigue decodificando fotogramas mientras nadie lo ve. Con cuatro, el teléfono se calienta y el scroll empieza a saltar — y el culpable aparente será la animación, que es lo que se ve moverse.
El patrón es un IntersectionObserver que haga dos cosas distintas y no las confunda:
- Montar la fuente una sola vez, unos 300 px antes de entrar. Ese gasto de red no tiene vuelta atrás.
- Arrancar y parar según se vea o no. Eso sí va y viene, y es lo que ahorra batería.
Lo mismo vale para cualquier lienzo con WebGL: fuera de pantalla, se para el bucle.
6 · Carga el motor pesado sólo si se va a usar
Si tienes un efecto que sólo existe con ratón —un cursor a medida, un hover con
shaders—, un import estático lo mete en el paquete principal para todo el mundo,
teléfonos incluidos, donde la condición nunca se cumple. Un import() dinámico detrás del
if lo convierte en un trozo aparte que sólo se pide cuando hace falta. En esta página son
33 KB que un móvil no descarga jamás.
7 · prefers-reduced-motion no es una casilla de accesibilidad
Respetarlo bien tiene dos consecuencias que casi nadie aprovecha:
- Es la ruta más rápida de la página. Quien pide menos movimiento debería descargar menos JavaScript, no el mismo con las animaciones apagadas.
- Te obliga a escribir el estado final en el HTML y animar desde ahí. Eso arregla de paso el rastreo y la robustez: si el guion falla, lo que queda en pantalla es el contenido, no una fila de rectángulos invisibles esperando un tween que no va a llegar.
8 · Mídelo en el aparato que importa
Un MacBook a 120 Hz esconde absolutamente todo. El presupuesto real es de 16,7 ms por fotograma a 60 Hz, y de esos el navegador ya gasta unos cuantos por su cuenta.
Lo que miramos antes de dar por buena una página:
- Performance con estrangulamiento de CPU ×4 o ×6. Si aparecen barras rojas de layout durante el scroll, hay una propiedad mal elegida.
- La capa de Rendering con «Paint flashing»: lo que parpadea es lo que se repinta, y no debería parpadear casi nada mientras haces scroll.
- CLS por debajo de 0,1. La mayor parte de las animaciones de entrada mal hechas se pagan aquí.
- Un teléfono real, con datos móviles, sin caché.
El resumen, en cinco líneas
transformyopacity; el resto sólo con motivo.- FLIP cuando la caja cambie de verdad.
- Un disparador por sección,
onceen las entradas,refresh()enload. - Nada decodificando fuera de pantalla; los motores pesados, en
import()dinámico. - El estado final en el HTML, y la prueba en un teléfono estrangulado.
Los componentes del catálogo de esta casa están escritos con estas reglas puestas, y cada uno lleva anotado por qué hace lo que hace. Es el mismo criterio, sólo que ya resuelto.