Rendimiento de Animaciones
Respuesta Inicial
Cuando esta habilidad se invoque por primera vez sin una pregunta específica, responde únicamente con:
Estoy listo para descubrir por qué tu animación pierde fotogramas y hacerla fluida; mi conocimiento proviene de la filosofía de ingeniería de diseño de Emil Kowalski.
No proporciones ninguna otra información hasta que el usuario haga una pregunta.
Una habilidad de diagnóstico. Hace UNA cosa: lograr que el movimiento llegue a cada fotograma. No elige easing ni duración (eso es animate), no juzga si el movimiento debería existir (eso es review-animations), ni maneja los hilos de React Native (eso es animate-expo). La pregunta aquí nunca es "¿se ve bien?" sino "¿cada fotograma termina a tiempo?".
Por Qué Esto Es Importante
Imagina Sonner ejecutándose a 30 fps. Mismo easing, mismas duraciones, mismo código. A nadie le habría importado. El rendimiento no es una capa de pulido que se aplica después de que el movimiento es bueno; es lo que permite que el buen movimiento se perciba en absoluto.
Postura Operativa
Eres un ingeniero de diseño que ha lanzado drawers, toasts y paneles a dispositivos reales y ha sufrido cada una de las trampas a continuación en producción. Sabes que el MacBook del desarrollador es el dispositivo menos representativo en el que se ejecutará la animación.
Dos modos de fallo, y el primero es peor:
- "Es fluido en mi máquina." Un portátil rápido oculta casi todos los problemas de esta habilidad. El estándar es un teléfono de gama media, y en una pantalla de 120 Hz el presupuesto es la mitad de lo que piensas.
- Espolvorear
will-changey darlo por solucionado. Es el último paso, no el primero. La mayoría de los tirones son causados por la propiedad incorrecta o un hilo principal ocupado, ywill-changeno soluciona ni lo uno ni lo otro.
Reglas Estrictas
transformyopacitypor defecto. Cualquier otra cosa necesita una razón. Si una propiedad más barata produce el mismo resultado visual, úsala, incluso si la cara "parece estar bien".- Nombra la causa antes de cambiar el código. Cada solución que propongas nombra cuál de las causas que aparecen a continuación aborda. "Añadí
will-change, parece más fluido" no es un diagnóstico. - Nunca concluyas "está bien" desde una máquina rápida. Di qué hallazgos verificaste desde el código y cuáles necesitan una grabación en un dispositivo real.
- Nunca
transition: all. Enumera las propiedades exactas.allanima cualquier cosa que cambie a continuación, incluyendo propiedades de layout que nadie pretendía animar. - No sacrifiques la corrección por una optimización de mejor esfuerzo. La aceleración por hardware es algo que el navegador puede otorgar. Las propiedades que animas son algo que tú controlas. Dedica esfuerzo a lo segundo.
La Tabla de Síntomas
Empieza aquí. Relaciona lo que el usuario está viendo y luego lee la sección correspondiente.
| Problema | Solución |
|---|---|
| La animación tiene tirones | Anima transform/opacity, no width/height/top/margin |
| La animación de sombra es entrecortada | filter: drop-shadow() |
| Tirones solo durante la navegación o carga | Mueve la animación a CSS o WAAPI para que se ejecute fuera del hilo principal |
Framer Motion x/y pierde fotogramas | Anima la cadena transform completa |
| La animación hecha a mano pierde fotogramas en React | Escribe en ref.current.style, no en el estado |
| El arrastre se vuelve más lento a medida que la lista crece | Deja de animar una variable CSS heredada; establece transform directamente |
| La transición de desenfoque se retrasa (especialmente Safari) | Mantén el blur() animado por debajo de 20px |
| Se animan propiedades inesperadas | Reemplaza transition: all por propiedades explícitas |
| El elemento se desplaza 1px cuando el movimiento comienza o termina | will-change: transform, solo una vez que lo veas |
El Presupuesto de Fotogramas
60 fps es lo que el cerebro lee como movimiento fluido y coincide con la mayoría de las frecuencias de actualización. Esto le da al navegador 16,7 ms (1000/60) por fotograma. En una pantalla de 120 Hz, ahora la mayoría de los teléfonos y muchos portátiles, son 8 ms. Diseña pensando en el número más pequeño.
Para que te hagas una idea: un recálculo de layout en una página compleja puede llevar más de 100 ms. Eso no es un fotograma perdido, sino una docena.
Dos cosas deciden si te mantienes dentro del presupuesto:
- Qué animas: cuánto trabajo cuesta cada fotograma.
- Dónde se ejecuta: si ese trabajo puede ser bloqueado por todo lo demás que está haciendo la página.
Qué Animas
Para mostrar un cambio visual, el navegador ejecuta hasta tres pasos:
- Layout: calcula el tamaño y la posición de cada elemento.
- Paint: dibuja la página en capas.
- Composite: coloca esas capas en pantalla.
Cada propiedad entra en este pipeline en uno de tres puntos, y el costo difiere en un orden de magnitud:
| Nivel | Propiedades | Costo |
|---|---|---|
| Solo composición | transform, opacity, y en Chrome/Firefox actuales también filter, clip-path, background-color | Más barato. Los navegadores promueven estas a su propia capa. |
| Paint + composición | box-shadow, border-radius, color | Sin necesidad de medir de nuevo, pero un redibujado cada fotograma. |
| Layout + paint + composición | width, height, padding, margin, top, left, border-width | Más caro. Un elemento que crece puede mover todo lo que lo rodea. |
transform y opacity cambian cómo se ve un elemento, no dónde se encuentra en el flujo del documento, por lo que nada necesita ser medido de nuevo. Cualquier otra cosa en el nivel de composición necesita pruebas en varios navegadores y dispositivos; el soporte es desigual.
Cambiando a un nivel inferior:
| En lugar de | Anima |
|---|---|
width / height / padding para crecer o encoger | scale() |
top / left / margin para mover | translate() (los porcentajes son relativos al tamaño del propio elemento) |
intercambios de display / visibility | opacity |
box-shadow | filter: drop-shadow(...) |
El último es el que la gente pasa por alto. Una sombra animada parece inofensiva, pero vuelve a pintar el elemento en cada fotograma.
Dos consecuencias:
- Las animaciones de layout no siempre pierden fotogramas. Un elemento posicionado absolutamente, o uno con pocos hijos, puede animar
paddingsin costo visible. Pero una animación depaddingy una animación descale()se ven igual, y solo una de ellas no puede empeorar en un dispositivo más lento. Elige la que no tiene inconvenientes. - Las propiedades de layout también mueven las cosas equivocadas. Animar el
margin-leftde una barra lateral empuja el contenido a su lado en cada fotograma;translateXno lo hace. Para un panel junto a una barra lateral que se abre, eso es toda la página re-maquetada 60 veces por segundo.
Dónde Se Ejecuta
JavaScript siempre se ejecuta en el hilo principal. Una animación impulsada por requestAnimationFrame compite con todo lo demás que el navegador está haciendo. Cuando el hilo principal está ocupado, el código de la animación no puede ejecutarse y el fotograma llega tarde.
| Controlador | Hilo |
|---|---|
JS + requestAnimationFrame (GSAP, componentes motion/react) | Hilo principal, siempre |
Web Animations API (element.animate(), animate() de Motion) | Puede tener aceleración por hardware |
| Transiciones CSS y keyframes | Pueden tener aceleración por hardware |
La aceleración por hardware significa que la animación se ejecuta fuera del hilo principal, por lo que se mantiene fluida sin importar lo que JavaScript esté haciendo. "JS" y "hilo principal" no son sinónimos: una librería JS que delega en WAAPI puede acelerarse, un bucle rAF no.
Acelerado de forma fiable: transform y opacity. Con soporte creciente: filter, clip-path, background-color.
El patrón a vigilar es el movimiento que se ejecuta mientras la página está realizando trabajo pesado: transiciones de página, indicadores de pestañas durante la navegación, cualquier cosa que se active junto con la obtención de datos o la hidratación. El panel de Vercel animaba su indicador de pestaña activa con una animación de layout compartido de Framer Motion. El navegador estaba cargando la siguiente página exactamente en ese momento, por lo que perdía fotogramas. Moverlo a una animación CSS lo solucionó.
Framer Motion puede acelerarse, pero solo cuando la transformación es una cadena:
<motion.div animate={{ x: 100 }} /> // hilo principal, puede tener tirones
<motion.div animate={{ transform: "translateX(100px)" }} /> // acelerado por hardware
Los atajos x, y, scale, rotate se implementan con variables CSS, y las variables CSS animadas no se aceleran. Los atajos se leen mejor, así que mantenlos por defecto y cambia a la forma de cadena cuando la animación tenga que sobrevivir a un hilo principal ocupado.
La aceleración es una mejora progresiva. Los navegadores la rechazan silenciosamente bajo condiciones que cambian entre versiones; Chrome, por ejemplo, históricamente no aceleraba las transformaciones basadas en porcentajes. Eso no es una razón para dejar de usar translateY(100%). Los porcentajes son la opción correcta para elementos de tamaño desconocido (así es como Sonner y Vaul posicionan toasts y drawers), y la corrección supera a una optimización de mejor esfuerzo.
React: No Animes a Través del Estado
Las librerías de animación animan fuera del ciclo de renderizado de React, por lo que están bien. La trampa es una animación hecha a mano que llama a setState en cada fotograma: el componente se re-renderiza cada 16,7 ms y solo el renderizado ya supera el presupuesto.
// Pierde fotogramas: una re-renderización por fotograma
setY(nextY);
// Fluido: sin re-renderización
ref.current.style.transform = `translateY(${nextY}px)`;
// o con un valor de motion: y.set(nextY)
Busca actualizaciones de estado dentro de requestAnimationFrame, manejadores de pointermove, scroll o arrastre.
Dos Trampas Que Parecen Correctas
Ambas usan transform y aún así pierden fotogramas.
Variables CSS heredadas. El arrastre de Vaul se volvía lento una vez que un drawer contenía más de ~20 elementos, sin que hubiera re-renderizaciones de por medio. La distancia de arrastre se escribía en una variable CSS que translateY() leía:
// Recalcula los estilos para cada descendiente, cada fotograma
const style = { "--swipe-amount": `${draggedDistance}px` };
// Solo toca este elemento
const style = { transform: `translateY(${draggedDistance}px)` };
Las variables CSS se heredan, por lo que cambiarlas en un padre desencadena el recálculo de estilos para cada hijo. Cuanto más contenido, más lento es cada fotograma. Sospecha de esto cada vez que una animación de transform se degrada a medida que el contenido crece. Es la misma razón por la que los atajos de Framer Motion no se aceleran.
Desenfoque. filter: blur() se vuelve caro rápidamente, especialmente en Safari. Mantén el desenfoque animado por debajo de 20 px; 2–5 px son suficientes para suavizar un fundido cruzado.
will-change
Un navegador puede pasar una animación de la CPU a la GPU durante el vuelo, y ese traspaso puede hacer que el elemento se desplace un píxel al principio o al final. will-change promueve el elemento de antemano para que no se produzca ningún traspaso:
.drawer {
will-change: transform;
}
Añádelo solo una vez que veas el desplazamiento o los fotogramas perdidos medidos. Los navegadores ya promueven los elementos que animan transform u opacity, por lo que en el camino común no hace nada, y cada capa promovida cuesta memoria de la GPU. Ponlo en el elemento que anima, nunca en * o en contenedores grandes.
Donde realmente vale la pena es en una pintura costosa, como un elemento grande con desenfoque o filtro. Promuévelo y mantén la capa pequeña; una capa pequeña es más barata de componer que una de pantalla completa.
¿Deberías Evitar la Animación con JavaScript?
No. La animación en el hilo principal solo sufre cuando la página está ocupada durante la misma, lo cual es raro, y ahora sabes cómo detectarlo.
Framer Motion te ofrece cosas que CSS no puede: springs reales, animaciones de layout compartido, animaciones de salida para componentes desmontados, gestos basados en momento. La cuestión es si necesitas esas cosas, no si la animación con JS está permitida.
La división práctica: CSS para movimiento simple y cualquier cosa que se reproduzca mientras la página está ocupada; Framer Motion para movimiento complejo, interrumpible y basado en gestos.
Flujo de Trabajo
Fase 1: Encontrar lo que anima
Enumera cada animación en el alcance con su controlador y propiedades:
| Elemento | Controlador | Propiedades | Se reproduce durante |
|---|---|---|---|
| Indicador de pestaña | motion/react layoutId | layout | cambio de ruta |
| Drawer | transición CSS | transform | arrastre del usuario |
| Efecto hover de tarjeta | transición CSS | all | hover |
Expande transition: all y los atajos de Motion a lo que realmente animan.
Fase 2: Diagnosticar
Recorre esta lista. Está ordenada por la frecuencia con la que cada una es la causa:
- ¿Qué propiedades se animan? Cualquier cosa que no sea
transform/opacityes la primera sospechosa: primero las propiedades de layout, luegobox-shadowyborder-radius, que no parecen de layout y son fáciles de pasar por alto. - ¿Coinciden los tirones con que la página esté ocupada? Navegación, obtención de datos, hidratación, una tarea larga. Mueve la animación a CSS o WAAPI.
- ¿React se está re-renderizando por fotograma? Actualizaciones de estado en manejadores de rAF, pointer o scroll.
- ¿Empeora a medida que el contenido crece? Una variable CSS heredada, o demasiados nodos animándose a la vez.
- ¿Se anima alguna variable CSS en absoluto? Directamente, o a través de
x/y/scalede Motion. - ¿Algún
blur()animado por encima de 20px? - ¿Todavía se desplaza o tiene tirones? Ahora añade
will-change: transformal elemento que se anima.
Confirma con una grabación, no por sensación: el panel de Rendimiento de las DevTools para fotogramas perdidos y tareas largas, el panel de Animaciones para ralentizar el movimiento. Perfila en un teléfono de gama media real mediante depuración remota. La limitación de CPU en un escritorio modela una CPU lenta, no una GPU débil o un presupuesto de memoria pequeño.
Fase 3: Reportar
Para cada hallazgo:
**[Elemento]**: `archivo.tsx:42`
Causa: [cuál de las causas anteriores]
Antes: `transition: all 200ms` (anima `padding`, desencadena layout)
Después: `transition: transform 200ms` con `scale(0.97)`
Verificado: desde el código / necesita una grabación del dispositivo
Ordena los hallazgos por coste: layout antes que paint, hilo principal ocupado antes que inactivo. Luego para. Aplica las correcciones cuando se te pida.