Animación en scroll: por qué se siente mal y cómo se arregla.

MotionGSAPRendimiento

Casi siempre que una animación de scroll se siente mal, el problema no es la velocidad ni la curva. Es que la posición del elemento deja de ser una función continua del scroll.

Una animación ligada al scroll tiene una propiedad rara: el tiempo lo controla el usuario. Puede ir rápido, lento, pararse a la mitad y dar marcha atrás. Eso destapa errores que en una animación normal, con su duración fija, nadie llega a ver.

Construyendo esta web nos topamos con unos cuantos. Aquí va cada uno, qué aspecto tenía y qué lo arregló de verdad. Casi todos cuestan lo mismo de aceptar: el scroll no es una línea de tiempo.

El rebote que no es un rebote

El síntoma es un tirón sutil a mitad de camino, como si el elemento hubiera chocado con algo. La causa suele ser una cadena de tweens sobre la misma propiedad.

// Tres tweens encadenados sobre la misma y
tl.to(el, { y: 300, ease: 'power2.inOut' })
  .to(el, { y: 600, ease: 'power2.inOut' })
  .to(el, { y: 900, ease: 'power2.inOut' });

Parece continuo porque las posiciones encajan: donde acaba 300 empieza 600. Pero `power2.inOut` llega con velocidad cero y el siguiente tween vuelve a acelerar desde cero. La posición es continua; la velocidad no. El ojo lee esa discontinuidad como un golpe.

Conviene decirlo con precisión, porque explica una familia entera de problemas. La suavidad no es una propiedad de la posición. Es una propiedad de sus derivadas. Un recorrido puede ser perfectamente continuo y verse roto si su primera derivada da un salto, y si la que salta es la segunda lo que se percibe es un golpe blando en lugar de un choque.

La solución no es otra curva, es otro modelo. En vez de encadenar tramos, escribe la posición como una función del progreso y evalúala en cada fotograma.

const apply = (progress) => {
  const y = curve(progress) * TRAVEL;
  setY(y);            // gsap.quickSetter
};

ScrollTrigger.create({
  trigger: section,
  scrub: 1.3,
  onUpdate: (self) => apply(self.progress),
});

Con una sola función, la velocidad es continua por construcción. No hay costuras porque no hay tramos. Además lo vuelve depurable de una manera que una línea de tiempo no permite: puedes muestrear la función en mil puntos y mirar el resultado sin tocar el DOM.

Una cinta continua que traza una sola curva sin ninguna unión a lo largo de su recorrido.

¿Por qué mi animación en scroll se siente a tirones?

Casi siempre porque está montada como una cadena de tweens sobre la misma propiedad. Las curvas ease-in-out terminan con velocidad cero y el siguiente tween vuelve a acelerar desde cero: la posición sigue siendo continua, la velocidad no, y el ojo lee ese salto como un tirón. La solución es escribir la posición como una única función continua del progreso del scroll y evaluarla en cada fotograma, en vez de encadenar tramos. Si aun así se siente mal, los siguientes sospechosos son animar una propiedad que provoca cálculo de diseño y un scrub tan bajo que la animación copia el ruido del gesto.

Las curvas mienten sobre dónde está el movimiento

Una trampa emparentada, y que nos costó una tarde entera en una transición con desenfoque. El elemento tenía 900 milisegundos y una curva expo.out, y se veía instantáneo con una cola larga de nada.

Eso es exactamente lo que hace expo.out. Alrededor del 80% de su recorrido ocurre en el primer 20% de su duración. En una respuesta corta de interfaz eso se lee como agilidad, y por eso ahí es un buen valor por defecto. En una transición lenta y deliberada se lee como un fallo seguido de una espera.

La lección se generaliza: duración y curva no son ajustes independientes. Una duración larga con una curva de salida agresiva no es una animación lenta, es una animación rápida con relleno. Cuando algo tiene que sentirse sin prisa, la curva tiene que repartir su recorrido, y eso normalmente significa una curva in-out o un bezier propio y suave.

Un setter de GSAP que no hace nada

Este también nos costó una tarde. Queríamos escalar un elemento en cada fotograma y fuimos a lo evidente:

const setScale = gsap.quickSetter(el, 'scale'); // no hace nada

El elemento se movía y giraba, pero no escalaba nunca. Ni error en consola ni aviso. Leer el estilo en línea lo explicó:

translate: none; rotate: none; scale: none;
transform: translate3d(...) rotate(...) rotateX(...);

GSAP fija a none las propiedades CSS sueltas `translate`, `rotate` y `scale` para que no compitan con su propio `transform`. Un quickSetter sobre `"scale"` escribe justo esa propiedad neutralizada. La opacidad funcionaba todo el rato porque es una propiedad corriente que GSAP no fija.

`scaleX` y `scaleY` sí son componentes reales del transform y caen en la misma caché que `x`, `y` y `rotation`. Es la diferencia entre funcionar y fallar en silencio, y no está documentado en ningún sitio evidente.

La costumbre que nos dejó esto vale más que el arreglo concreto. Cuando una propiedad se niega a animarse y no salta ningún error, lee el estilo computado en lugar del código. El navegador te dice qué se escribió de verdad, y con frecuencia no es lo que creías haber escrito.

Curvas con una esquina donde no la quieres

Queríamos pantallas que entraran por la derecha y también salieran por la derecha. Suena trivial hasta que lo escribes: el desplazamiento lateral tiene que seguir siendo positivo a los dos lados del punto de reposo.

`sen θ` no sirve, porque es impar: si entras en positivo, sales en negativo. `|sen θ|` cumple el requisito pero tiene una esquina justo en el cero, y esa esquina es una inversión brusca de la velocidad lateral. Se ve.

Una fila de esferas separadas de forma regular en los extremos y apelotonadas en un punto cerca del centro.
const CORNER = 0.11;
const softAbs = (v) => (v * v) / Math.sqrt(v * v + CORNER * CORNER);

Esa función sigue a `|v|` en cuanto el valor se aleja del cero y se vuelve cuadrática cerca de él. La velocidad pasa por cero suavizándose en lugar de rebotar. Lo comprobamos muestreando el recorrido en doscientos mil puntos: la peor aceleración lateral se queda en torno al 3% de la velocidad máxima.

La constante es todo el diseño. Demasiado pequeña y la esquina vuelve, porque la zona cuadrática se encoge por debajo del ancho de un fotograma. Demasiado grande y el elemento duda visiblemente en el centro, porque ahora pasa tiempo real cerca del cero. El 0,11 salió mirando, no derivando, y esa es una descripción honesta de cómo se fijan casi todas estas constantes.

¿Cómo se hace que un elemento entre y salga por el mismo lado sin dar un tirón?

Hace falta una función que se mantenga positiva a los dos lados del punto de reposo, y cualquier curva así que toque el cero tiene una esquina ahí. La solución es un valor absoluto suavizado: (v·v) / raíz(v·v + k·k), con una k pequeña. Sigue a |v| lejos del cero y se vuelve cuadrática cerca, así que la velocidad cruza el cero de forma continua en vez de invertirse en un fotograma. La k se ajusta a ojo: demasiado pequeña y vuelve la esquina, demasiado grande y el elemento duda en el centro.

El scrub y por qué el scroll suave cambia el encargo

El scrub es el retraso entre la posición del scroll y la animación alcanzándola. Puesto en true, la animación queda pegada al scroll y sigue cada temblor del trackpad. Puesto en un número, se acerca al objetivo suavizando durante esos segundos.

Pegada al scroll suena correcto y suele sentirse peor. La entrada real de scroll es ruidosa, y una animación que reproduce el ruido con fidelidad parece nerviosa. Un scrub de entre 1 y 1,5 actúa como un filtro sobre la mano del usuario.

El scroll suave luego lo multiplica, y ahí es donde se tuercen muchas webs. Una librería de scroll suave ya está interpolando la posición hacia su objetivo. Añadir encima un scrub alto son dos filtros en serie, y el resultado es una animación arrastrada por almíbar que llega siempre después de que el lector haya dejado de mirar.

Los dos ajustes hay que elegirlos juntos. Nuestro scroll suave va con un factor de interpolación bajo, que ya de por sí es suave, así que el scrub que va encima es 1,3 y no el 2 o 3 que parece correcto cuando se prueba contra el scroll nativo. Si cambias uno, vuelve a probar el otro.

Un detalle de implementación que elimina una categoría entera de microcortes: mueve el scroll suave desde el mismo reloj que la librería de animación, en vez de dejar que cada uno tenga su propio bucle de fotogramas. Dos bucles son dos oportunidades por fotograma de no ponerse de acuerdo sobre qué hora es.

// Un solo reloj para los dos, para que no se separen.
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);

Solo dos propiedades son gratis, el resto es una factura

Animar en scroll significa recalcular en cada fotograma que produce el usuario, que en una pantalla de 120Hz es un presupuesto de unos ocho milisegundos para todo, incluido lo demás que esté haciendo la página.

El transform y la opacidad los lleva el compositor y cuestan casi nada por fotograma. Todo lo demás devuelve trabajo al hilo principal. Width, height, top, margin y padding obligan a recalcular el diseño, lo que significa que el navegador rehace la geometría de la página y luego la vuelve a pintar. Hacer eso sesenta veces por segundo es como una máquina rápida acaba perdiendo fotogramas.

`filter: blur()` merece un aviso aparte porque no es una propiedad de diseño y aun así destroza el rendimiento en scroll. Es una operación por píxel sobre todo el elemento, recalculada en cada valor, y en un elemento grande es lo bastante cara como para verse en un portátil y ser inusable en un móvil.

Y eso importa más de lo que parece, porque la distancia entre donde se construyen estas cosas y donde se usan se puede medir.

77%
de las páginas móviles pasan el umbral de INP que Google usa para medir la respuesta, frente al 97% en escritorio. Esos 20 puntos son sobre todo trabajo dimensionado para la máquina donde se escribió. Web Almanac 2025, HTTP Archive

Dos costumbres mantienen esto honesto. Escribe los valores con un setter que se salte el coste por llamada de GSAP cuando actualizas en cada fotograma, y no leas nunca el diseño dentro de la actualización. Un solo `getBoundingClientRect` dentro de un manejador de scroll obliga al navegador a aplicar los cambios pendientes antes de poder responder, que es la manera clásica de convertir una animación suave en un pase de diapositivas.

  • Guarda en caché todo lo que midas y vuelve a calcularlo al redimensionar, no al hacer scroll.
  • Pon `will-change` cuando arranca una animación y quítalo cuando termina, porque dejarlo puesto para siempre cuesta memoria en cada elemento que lo lleve.
  • Prefiere un elemento recorriendo mucho a muchos elementos recorriendo poco, porque el coste crece con el número de capas que tiene que gestionar el compositor.

El movimiento reducido es un ajuste, no una casilla

Hay bastante gente con el movimiento reducido activado en su sistema operativo, y una parte lo tiene porque las áreas grandes en movimiento les sientan mal. La animación ligada al scroll es justo la categoría que lo provoca.

La implementación habitual está mal de una forma concreta. Se entiende que desactivar la animación es desactivar el código que la ejecuta, y como ese código es también el que hace visible el elemento, el contenido no aparece nunca.

// Mal: la sección se queda en opacidad 0 para siempre.
if (prefersReducedMotion()) return;

// Bien: sáltate el viaje, conserva el destino.
if (prefersReducedMotion()) {
  gsap.set(elements, { opacity: 1, y: 0, clearProps: 'transform' });
  return;
}

La norma que mantiene esto en su sitio: el movimiento reducido quita la transición, nunca el resultado. Todo lo que iba a hacerse visible se hace visible de inmediato.

Cómo comprobar que funciona de verdad

El error de comprobación que más nos costó fue medir posiciones con `getBoundingClientRect` y darlo por bueno. Los rectángulos dicen dónde está una caja, no si hay algo opaco pintado encima. Dos veces dimos por buena una sección que estaba en blanco.

`document.elementFromPoint` responde a la pregunta correcta, que es qué se ve en ese píxel. También conviene recorrer la opacidad de los ancestros: si un contenedor está a cero, sus hijos siguen diciendo que su opacidad es 1 en su propio estilo computado, así que una comprobación ingenua sobre el elemento pasa mientras no hay nada en pantalla.

// ¿Es este elemento lo que un usuario tocaría de verdad?
const r = el.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
console.log(el.contains(hit) || hit === el);

// ¿Y hay algún ancestro escondiéndolo?
let node = el, alpha = 1;
while (node) { alpha *= +getComputedStyle(node).opacity; node = node.parentElement; }

Un aviso más aprendido por las malas. Muestrear posiciones mientras mueves el scroll por código da lecturas falsas si las muestras llegan más rápido de lo que el scroll suave tarda en asentarse. Con un factor de interpolación bajo, la posición sigue moviéndose un segundo o más después de la última entrada, así que un bucle que hace scroll y mide acto seguido está midiendo un transitorio.

¿La animación en scroll perjudica al rendimiento y al SEO?

No perjudica a ninguno de los dos si está bien hecha, y perjudica a los dos si no lo está. En rendimiento, animar solo transform y opacidad mantiene el trabajo en el compositor y fuera del hilo principal; animar propiedades de diseño o desenfoques hace lo contrario. En SEO, el contenido tiene que existir en el HTML y verse sin que haya corrido JavaScript, así que las animaciones de aparición deberían partir de un estado visible y esconderse por código, no venir escondidas en el marcado. Una página cuyo texto solo aparece cuando se dispara un manejador de scroll es una página que algunos rastreadores anotarán como vacía.

Ninguno de estos arreglos cambia el diseño. Cambian si el diseño parece hecho o comprado, que en una web es una diferencia que los visitantes notan sin saber nombrarla.

Más recursos