El scroll suave bien hecho no se nota: se siente como si la página tuviera inercia. Mal hecho, rompe position: sticky, desincroniza las animaciones y castiga a quien prefiere menos movimiento. Esta es la receta que usamos en proyectos con Next.js, GSAP y Lenis.
La idea: un solo reloj
Lenis calcula la posición suavizada de cada fotograma y GSAP anima con su propio ticker. Si cada uno usa su reloj, aparecen saltos. La solución es que Lenis avance dentro del ticker de GSAP y que ScrollTrigger se actualice cada vez que Lenis se mueve.
"use client";
import { useEffect } from "react";
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
export function SmoothScroll({ children }: { children: React.ReactNode }) {
useEffect(() => {
const lenis = new Lenis({
duration: 1.05,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
});
lenis.on("scroll", ScrollTrigger.update);
const tick = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(tick);
gsap.ticker.lagSmoothing(0);
return () => {
gsap.ticker.remove(tick);
lenis.destroy();
};
}, []);
return <>{children}</>;
}
Dos detalles importan. GSAP trabaja en segundos y Lenis en milisegundos, de ahí el time * 1000. Y lagSmoothing(0) evita que GSAP "salte" el tiempo cuando la pestaña se ralentiza, algo que con Lenis se notaría como un tirón.
Cuándo no activarlo
El scroll suave no es para todos los contextos. Nuestra regla:
- Movimiento reducido: si
prefers-reduced-motion: reduce, se usa el scroll nativo. - Táctil: en móviles el scroll nativo ya tiene inercia y es más fiable. Activamos Lenis solo con
(any-pointer: fine).
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const fine = matchMedia("(any-pointer: fine)").matches;
if (reduce || !fine) return; // scroll nativo
Ancla, bloqueo y navegación
Tres cosas más que conviene resolver desde el principio:
- Anclas: con la opción
anchors: true, Lenis intercepta los enlaces#idy los anima. - Bloquear el scroll (menús, modales, preloader):
lenis.stop()ylenis.start(), más una clase enhtmlparaoverflow: hidden. - Cambio de ruta: al navegar, devuelve el scroll a cero con
lenis.scrollTo(0, { immediate: true })y llama aScrollTrigger.refresh()cuando la nueva página esté montada.
Resumen
Un solo reloj, desactivarlo cuando no ayuda y exponer funciones para bloquear y volver arriba. Con eso el scroll suave deja de ser una fuente de errores y vuelve a ser lo que debe ser: un detalle que casi nadie nota, pero que todos sienten.