ScrollToPlugin: llevar el scroll a un sitio concreto
Las formas del valor scrollTo, el desplazamiento por compensación de cabeceras, autoKill y su callback, y el conflicto con scroll-behavior smooth.
Llevar la página a una posición concreta parece resuelto por el navegador: existe scrollIntoView, existe window.scrollTo con comportamiento suave, y existe scroll-behavior: smooth en CSS. Lo que ninguno de los tres da es control sobre la curva de aceleración, coordinación con otras animaciones en la misma timeline, compensación por una cabecera fija, ni cancelación limpia cuando el usuario decide tomar el control a mitad. ScrollToPlugin es un plugin pequeño que resuelve exactamente esas cuatro cosas.
- Escribir un
scrollToen todas sus formas, incluida la de objeto con sus claves. - Compensar una cabecera fija con los desplazamientos.
- Configurar la cancelación automática y reaccionar a ella.
- Evitar el conflicto documentado con el comportamiento suave nativo.
Las formas del valor
El objetivo del tween es window para la página, o un elemento con desbordamiento desplazable para un contenedor.
import { gsap } from "gsap";
import { ScrollToPlugin } from "gsap/ScrollToPlugin";
gsap.registerPlugin(ScrollToPlugin);
// Un numero: pixeles desde arriba
gsap.to(window, { duration: 1, scrollTo: 400 });
// Texto de selector o elemento
gsap.to(window, { duration: 1, scrollTo: "#seccion-3" });
// La posicion maxima del documento
gsap.to(window, { duration: 1, scrollTo: "max" });
// Objeto, con las claves disponibles
gsap.to(window, {
duration: 1,
scrollTo: { y: "#seccion-3", offsetY: 80, autoKill: true },
ease: "power2.inOut",
});
Las claves del objeto son y, x, offsetY, offsetX, autoKill y onAutoKill. Los ejes aceptan un número, texto de selector, un elemento o la palabra "max". Un valor suelto sin envolver en objeto se interpreta siempre como el eje vertical: scrollTo: "max" equivale a scrollTo: { y: "max" }.
Para desplazamiento horizontal en un contenedor:
gsap.to(".carril", { duration: 1, scrollTo: { x: 800 }, ease: "power2" });
Compensar la cabecera fija
Es el caso más frecuente en producción y la razón principal para no usar scrollIntoView. Si tienes una cabecera fija de 72 píxeles, llevar el scroll a un elemento hace que la cabecera lo tape.
offsetY resta esa cantidad al destino calculado.
const ALTO_CABECERA = 72;
document.querySelectorAll('a[href^="#"]').forEach((enlace) => {
enlace.addEventListener("click", (e) => {
e.preventDefault();
gsap.to(window, {
duration: 0.8,
ease: "power2.inOut",
scrollTo: { y: enlace.getAttribute("href"), offsetY: ALTO_CABECERA + 16 },
});
});
});
Ese bloque funciona pegado tal cual y sustituye al comportamiento por defecto de los enlaces de ancla en toda la página.
Merece la pena señalar que existe una alternativa en CSS puro para el mismo problema: scroll-margin-top en el elemento de destino, que el navegador respeta con el anclaje nativo. Si no necesitas control sobre la curva ni coordinación con otras animaciones, esa alternativa no requiere JavaScript y es preferible.
La documentación de GSAP lo dice explícitamente: usar ScrollToPlugin junto con scroll-behavior: smooth en CSS provoca conflictos. Los dos mecanismos intentan controlar la misma posición a la vez y el resultado es errático. Si vas a usar el plugin, quita esa regla del CSS.
autoKill y el respeto por el usuario
autoKill: true cancela lo que quede del tween si la posición del scroll cambia por una vía que no es el propio tween. Es decir, si el usuario mueve la rueda, arrastra la barra o pulsa una tecla de navegación mientras la página está viajando, el viaje se detiene.
Es un detalle de cortesía que se nota mucho. Sin él, el usuario que se arrepiente a mitad tiene que pelear contra la animación, que sigue tirando de la página hacia su destino.
onAutoKill se dispara cuando eso ocurre, y sirve para restaurar el estado de la interfaz: quitar el resaltado del elemento de destino, reactivar controles, o registrar que el usuario ha interrumpido.
gsap.to(window, {
duration: 1.2,
scrollTo: {
y: "#capitulo-4",
offsetY: 88,
autoKill: true,
onAutoKill: () => document.querySelector("#capitulo-4").classList.remove("destino"),
},
ease: "power2.inOut",
});
Existe además un configurador global, incorporado en la versión 3.12.6, para no repetir la opción en cada tween:
ScrollToPlugin.config({ autoKill: true });
Coordinar con otras animaciones
La ventaja que ninguna alternativa nativa ofrece: el desplazamiento es un tween de GSAP como cualquier otro, así que entra en una timeline y se sincroniza con el resto.
const tl = gsap.timeline();
tl.to(".menu", { autoAlpha: 0, duration: 0.25 })
.to(window, { duration: 0.9, scrollTo: { y: "#contacto", offsetY: 72 }, ease: "power2.inOut" })
.from("#contacto .campo", { autoAlpha: 0, y: 20, stagger: 0.08, duration: 0.4 }, "-=0.2");
Cierra el menú, viaja hasta el formulario, y revela sus campos con un solapamiento. Con las APIs nativas esa coordinación requiere encadenar promesas y escuchas de eventos de scroll.
Y si tienes ScrollSmoother en la página, este plugin no es la herramienta: usa smoother.scrollTo(destino, true, posicion), que integra el desplazamiento con el mecanismo de suavizado en lugar de competir con él.
Accesibilidad: el desplazamiento no solicitado
Mover el viewport es una de las pocas cosas que una página puede hacer sin que el usuario lo pida, y merece las mismas precauciones que cualquier animación de ese tipo.
Con la preferencia de movimiento reducido activa, lo correcto no es eliminar el desplazamiento —el usuario sigue queriendo llegar al destino— sino hacerlo instantáneo.
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
gsap.to(window, {
duration: reduce ? 0 : 0.8,
scrollTo: { y: "#seccion", offsetY: 72 },
ease: "power2.inOut",
});
Y hay un detalle que se olvida casi siempre: llevar el scroll a un elemento no mueve el foco. Alguien que navegue con teclado seguirá teniendo el foco donde estaba, así que su siguiente tabulación lo llevará de vuelta arriba. Si el desplazamiento representa una navegación, hay que mover el foco también.
const destino = document.querySelector("#seccion");
gsap.to(window, {
duration: 0.8,
scrollTo: { y: destino, offsetY: 72 },
onComplete: () => {
destino.setAttribute("tabindex", "-1");
destino.focus({ preventScroll: true });
},
});
El preventScroll: true es imprescindible: sin él, dar el foco provoca su propio desplazamiento y deshace la compensación de la cabecera.
Hay una jerarquía implícita entre las animaciones de una página que casi nunca se hace explícita, y que ordena bien las precauciones que hay que tomar con cada una. En el nivel más bajo están las que animan la apariencia de un elemento: un color, una sombra, una escala. Si fallan, no pasa nada. En el nivel intermedio están las que animan la posición o la existencia de un elemento: un panel que entra, una tarjeta que se mueve. Si fallan, algo puede quedar mal colocado, pero el usuario sigue orientado. Y en el nivel más alto está esta, la que mueve el punto de vista del usuario sobre el documento entero. Cuando animas el scroll no estás moviendo un objeto dentro de la escena: estás moviendo al espectador. Todas las referencias visuales cambian a la vez, y durante el trayecto el usuario no tiene ningún punto fijo con el que orientarse salvo lo que reconozca de pasada. Por eso la duración importa tanto más aquí que en cualquier otra animación —dos segundos de viaje por una página larga desorientan de verdad—, por eso autoKill no es un extra sino la manera de devolver el control a quien lo pidió, y por eso el foco tiene que viajar con la vista: si mueves al espectador y dejas su foco atrás, has separado dos cosas que para él son una sola. Hay una prueba sencilla que resume toda esta lección y que casi ninguna implementación pasa: navega con la tecla de tabulación hasta un enlace de ancla, púlsalo con la tecla de entrada, y sigue tabulando. Si el siguiente elemento enfocado está en la zona a la que acabas de llegar, tu desplazamiento está bien hecho. Si te devuelve a la cabecera, has animado la cámara y te has dejado al usuario donde estaba.
- Sustituye el comportamiento por defecto de todos los enlaces de ancla de una página por un tween con compensación de cabecera.
- Compara tu implementación con
scroll-margin-topen CSS puro y decide cuál necesitas. - Añade
scroll-behavior: smoothal CSS a la vez que el plugin y observa el conflicto. - Interrumpe un desplazamiento largo con la rueda, con y sin
autoKill. - Haz la prueba de la tabulación descrita en el callout y arregla lo que falle con
focus({ preventScroll: true }).