snap: ajustar el scroll a posiciones concretas
Las seis formas del valor de snap, el objeto de configuración completo, el ajuste direccional, y por qué el snap y la accesibilidad tienen una relación difícil.
El snap de ScrollTrigger no ajusta el scroll: ajusta el progress de la animación, y luego mueve el scroll a la posición que produce ese progress. La diferencia parece académica hasta que intentas hacer snap sobre una escena fijada y descubres que las posiciones a las que se ajusta no coinciden con ninguna frontera visual de tu página. Entendido el modelo, las seis formas del valor son fáciles, y las opciones del objeto resuelven cada una un problema muy concreto de sensación.
- Enumerar las seis formas del valor de
snapy elegir la adecuada para cada caso. - Configurar el objeto completo con
snapTo,duration,delay,ease,directionaleinertia. - Usar
"labels"y"labelsDirectional"sobre una timeline con etiquetas. - Valorar el coste de accesibilidad del snap y cuándo desactivarlo.
Seis formas del mismo valor
Todas las formas de snap producen un valor de progress entre 0 y 1, y ScrollTrigger traduce ese progress a una posición de scroll.
Número. snap: 0.25 ajusta a múltiplos de 0,25: 0, 0,25, 0,5, 0,75 y 1. Es la forma que quieres cuando tienes un número fijo de secciones, y el cálculo es siempre el mismo: uno partido por el número de secciones menos uno.
Array. snap: [0, 0.2, 0.55, 1] ajusta solo a esos valores concretos. Sirve cuando las paradas no están repartidas de forma uniforme.
Función. Recibe el valor de destino natural, calculado a partir de la velocidad, y devuelve el valor final. Es el escape para cualquier lógica propia.
"labels". Ajusta a la etiqueta más cercana de la timeline asociada. Requiere que la animación sea una timeline con etiquetas.
"labelsDirectional". Ajusta a la etiqueta más cercana en la dirección del último movimiento. Si empujas un poco hacia adelante y sueltas, va a la siguiente aunque la anterior estuviera técnicamente más cerca. Es notablemente más intuitivo y es lo que quieres casi siempre que uses etiquetas.
Objeto. La forma completa, con snapTo obligatorio y el resto opcional.
const secciones = gsap.utils.toArray(".seccion");
gsap.to(secciones, {
xPercent: -100 * (secciones.length - 1),
ease: "none",
scrollTrigger: {
trigger: ".carril",
pin: true,
scrub: 1,
end: () => "+=" + document.querySelector(".carril").offsetWidth,
snap: 1 / (secciones.length - 1),
},
});
El objeto completo
La forma de objeto acepta estas propiedades, y solo snapTo es obligatoria:
| Propiedad | Tipo | Qué controla |
|---|---|---|
snapTo |
Número, Array, Función o "labels" |
La lógica de ajuste, igual que las formas sueltas |
duration |
Número u objeto | Duración del ajuste. Con { min, max } se adapta a la velocidad |
delay |
Número | Espera desde que para el scroll hasta que empieza el ajuste |
ease |
Cadena o función | Ease del ajuste. Por defecto "power3" |
directional |
Booleano | Si va en la dirección del último movimiento. Activo por defecto desde la 3.8 |
inertia |
Booleano | Si tiene en cuenta la velocidad para elegir destino |
onStart |
Función | Al empezar el ajuste |
onInterrupt |
Función | Si el usuario interrumpe tocando el scroll |
onComplete |
Función | Al terminar el ajuste sin interrupción |
duration con rango es la más útil de todas y la menos conocida. Con duration: { min: 0.2, max: 0.6 }, un ajuste que solo tiene que recorrer veinte píxeles tarda 0,2 segundos, y uno que tiene que recorrer media pantalla tarda 0,6. Con una duración fija, el ajuste corto se siente perezoso y el largo apresurado.
delay por defecto vale la mitad del valor de scrub, o 0,1 si el scrub no es numérico. Subirlo evita que el ajuste se dispare mientras el usuario todavía está moviéndose a tirones; bajarlo lo hace más inmediato y más molesto.
snap: {
snapTo: "labelsDirectional",
duration: { min: 0.2, max: 0.6 },
delay: 0.15,
ease: "power2.inOut",
onInterrupt: () => console.log("el usuario tomo el control"),
}
Etiquetas: cuando las paradas no son uniformes
El ajuste por etiquetas es el que mejor escala, porque las paradas dejan de ser números mágicos y pasan a ser nombres puestos donde el contenido lo pide.
const tl = gsap.timeline({
scrollTrigger: {
trigger: ".narracion",
start: "top top",
end: "+=3600",
pin: true,
scrub: 1,
snap: { snapTo: "labelsDirectional", duration: { min: 0.25, max: 0.7 } },
},
});
tl.addLabel("intro")
.from(".capitulo-1", { autoAlpha: 0, y: 40, duration: 1 })
.addLabel("desarrollo")
.from(".capitulo-2", { autoAlpha: 0, y: 40, duration: 1 })
.addLabel("desenlace")
.from(".capitulo-3", { autoAlpha: 0, y: 40, duration: 1 })
.addLabel("final");
La ventaja frente al número es que puedes reordenar capítulos, cambiar duraciones o añadir uno nuevo sin tocar el snap. Y como ya viste, labelToScroll() traduce cada etiqueta a su posición de scroll, así que las mismas etiquetas te dan navegación por índice gratis.
En un móvil, el desplazamiento por inercia del sistema y el ajuste de ScrollTrigger compiten: el sistema quiere seguir deslizando y el snap quiere ir a una posición concreta. El resultado suele ser un tirón. Además, onInterrupt se dispara constantemente porque el toque táctil produce microeventos de scroll. Antes de dar el snap por bueno, pruébalo en un móvil real, y considera desactivarlo por debajo de cierto ancho.
El coste de accesibilidad
El snap le quita al usuario el control fino sobre la posición del scroll, y eso tiene consecuencias reales para gente concreta.
Quien navega con teclado usa las flechas para avanzar unas decenas de píxeles; con snap activado, cada pulsación puede terminar en un salto de pantalla completa. Quien usa un magnificador de pantalla necesita posicionar el contenido con precisión y el snap se lo impide. Y quien tiene sensibilidad vestibular experimenta un movimiento automático no solicitado cada vez que deja de hacer scroll, que es exactamente el patrón que prefers-reduced-motion intenta evitar.
Nada de eso significa que el snap esté prohibido. Significa que debería ser condicional.
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
ScrollTrigger.create({
trigger: ".carril",
pin: true,
scrub: 1,
end: "+=3000",
snap: reduce ? false : { snapTo: 1 / 4, duration: { min: 0.2, max: 0.6 } },
});
Esa comprobación puntual funciona, pero se queda obsoleta si el usuario cambia la preferencia con la página abierta. La forma robusta de condicionar animaciones a media queries, con limpieza automática incluida, es gsap.matchMedia(), y la verás en el nivel de integración y limpieza.
Hay una tentación estética que arruina muchas páginas: añadir snap porque queda bien en la demo. Lo que estás haciendo cuando activas el snap es cambiar el tipo del espacio de navegación. Sin snap, la posición del scroll es un número real y el usuario puede estar en cualquier punto; con snap, el conjunto de posiciones estables se vuelve finito y numerable, y el espacio entre ellas se convierte en territorio de paso por el que no puedes quedarte. Ese cambio es defendible cuando tu contenido ya era discreto: si tienes cinco paneles con cinco mensajes, las posiciones intermedias nunca significaron nada y el snap solo hace explícita una estructura que ya existía. Es indefendible cuando el contenido es continuo, y el caso paradigmático es un artículo largo: ahí la posición intermedia sí significa algo, porque significa “estoy leyendo el tercer párrafo”, y quitarle al lector la capacidad de quedarse donde quiere es quitarle la capacidad de leer a su ritmo. La prueba que resuelve la duda en diez segundos es esta: pregunta si un usuario tiene alguna razón legítima para querer detenerse entre dos puntos de ajuste. Si la respuesta es sí, el snap está mal, por bien que se sienta al deslizar. Y hay un corolario que suele pasarse por alto: el snap es una de las poquísimas funciones de una página web que mueve el viewport sin que el usuario lo haya pedido. Todas las demás cosas que hacen eso —un scroll automático, un anclaje que salta— se consideran hostiles y llevan décadas en las listas de malas prácticas. El snap sobrevive a esa crítica solo mientras el desplazamiento sea corto, predecible e interrumpible, y esas tres condiciones son las que estás configurando cuando ajustas duration, directional y onInterrupt.
- Monta un carril horizontal de cinco paneles con
snap: 1 / 4y comprueba que las paradas coinciden con los bordes de los paneles. - Cambia a
duration: { min: 0.2, max: 0.6 }y compara con una duración fija haciendo ajustes cortos y largos. - Sustituye el número por
"labelsDirectional"sobre una timeline con etiquetas y añade una parada nueva sin tocar la configuración del snap. - Navega con el teclado por una página con snap activado y anota cada punto en el que pierdes el control de la posición.
- Prueba tu snap en un móvil real y decide si merece la pena mantenerlo por debajo de 768 píxeles.