Observer: rueda, táctil y puntero bajo una sola API
Por qué unificar los tres modelos de entrada es difícil, qué normaliza Observer, el catálogo completo de callbacks, y el patrón de navegación sin scroll.
Detectar “el usuario ha hecho un gesto hacia abajo” parece una línea de código y son doscientas. Hay que escuchar wheel con sus tres modos de unidad, touchstart, touchmove y touchend con sus listas de toques, pointerdown y compañía con su modelo unificado que no todos los navegadores implementan igual, y después normalizar deltas que llegan en píxeles, en líneas o en páginas según el dispositivo y el navegador. Observer existe para que eso sean cuatro líneas, y una vez que lo tienes, abre la puerta a un tipo de interfaz que no depende del scroll en absoluto.
- Enumerar los cuatro tipos de entrada que Observer unifica y qué cubre cada uno.
- Elegir los callbacks adecuados entre los veintiún disponibles.
- Ajustar la sensibilidad con
tolerance,dragMinimumydebounce. - Construir una navegación por secciones sin scroll nativo.
Los cuatro tipos
Observer.create() acepta una lista separada por comas en type, con estos valores:
"wheel" cubre los eventos de rueda de ratón y de trackpad. "touch" cubre los eventos táctiles y también los de puntero en dispositivos táctiles. "pointer" cubre pulsación y arrastre con ratón o lápiz en dispositivos no táctiles. "scroll" cubre los eventos de scroll propiamente dichos, que son distintos de la rueda: un scroll puede venir del teclado, de una barra arrastrada o de un scrollTo.
El valor por defecto es "wheel,touch,pointer", sin "scroll".
import { gsap } from "gsap";
import { Observer } from "gsap/Observer";
gsap.registerPlugin(Observer);
Observer.create({
target: window,
type: "wheel,touch,pointer",
onUp: () => console.log("gesto hacia arriba"),
onDown: () => console.log("gesto hacia abajo"),
tolerance: 10,
preventDefault: true,
});
Ese código funciona y ya normaliza los tres modelos de entrada. Fíjate en que onUp y onDown se refieren a la dirección del movimiento, no a la del contenido: onDown se dispara cuando el gesto va hacia abajo, que en un scroll significa avanzar en la página.
Si ya tienes ScrollTrigger cargado, no necesitas importar Observer por separado: ScrollTrigger.observe() es funcionalmente idéntico a Observer.create(). Es un detalle que ahorra unos kilobytes en proyectos que ya usan los dos.
Los callbacks
Son veintiuno y se agrupan en cuatro familias. Merece la pena tener el mapa antes de elegir.
Dirección. onUp, onDown, onLeft, onRight. Se disparan cuando el movimiento va en cada sentido. Son los más usados.
Cambio. onChange para movimiento en cualquier eje, onChangeX y onChangeY para cada uno. onToggleX y onToggleY se disparan cuando el movimiento cambia de sentido en ese eje, lo cual detecta el gesto de “el usuario ha rectificado”.
Pulsación y arrastre. onPress, onRelease, onDragStart, onDrag, onDragEnd, onMove, onClick. Los de arrastre solo funcionan con los tipos "touch" y "pointer".
Otros. onWheel para la rueda específicamente, onHover y onHoverEnd para la entrada y salida del puntero, onStop cuando el movimiento cesa durante un intervalo, y onLockAxis cuando se fija un eje.
Todos reciben la instancia del Observer como único parámetro, y de ahí sacan el estado: deltaX y deltaY con el movimiento del tick, velocityX y velocityY con la velocidad, x e y con la posición actual del puntero, startX y startY con la de inicio del gesto, isDragging e isPressed con el estado, y event con el evento nativo que lo originó.
Observer.create({
target: ".lienzo",
type: "touch,pointer",
onDrag: (self) => {
gsap.set(".pieza", { x: `+=${self.deltaX}`, y: `+=${self.deltaY}` });
},
onDragEnd: (self) => {
console.log("velocidad final:", self.velocityX, self.velocityY);
},
});
Ajustar la sensibilidad
Cuatro opciones controlan cuándo se considera que ha habido un gesto, y elegirlas mal produce interfaces que responden a movimientos accidentales o que ignoran los intencionados.
tolerance es el número de píxeles de movimiento acumulado antes de que disparen los callbacks de dirección. Después de disparar, el contador se reinicia. Con tolerance: 10, un temblor de la mano de tres píxeles no dispara nada.
dragMinimum es el umbral en píxeles para que un movimiento tras una pulsación cuente como arrastre. Sirve para distinguir un clic de un arrastre corto.
debounce, activado por defecto, acumula los deltas dentro de un mismo frame en lugar de disparar el callback por cada evento nativo. Con él, un trackpad que emite cuarenta eventos por frame produce una llamada por frame con el delta total. No afecta a onPress, onRelease, onHover, onHoverEnd, onClick, onDragStart ni onDragEnd.
wheelSpeed y scrollSpeed son multiplicadores de los deltas de cada fuente. Un valor negativo invierte el sentido, lo cual sirve para respetar la preferencia de “scroll natural” o para invertirla.
lockAxis: true hace que el primer movimiento determine el eje y lo bloquee hasta soltar, y ignore excluye del cálculo a los elementos que le indiques, que es lo que necesitas para que un menú desplegable dentro de la zona observada no dispare los gestos de navegación.
El patrón de navegación sin scroll
Este es el uso que más se ve en producción: una página de pantalla completa donde el gesto de scroll no desplaza nada sino que dispara una transición entre secciones.
const secciones = gsap.utils.toArray(".seccion");
let actual = 0;
let animando = false;
gsap.set(secciones, { autoAlpha: 0 });
gsap.set(secciones[0], { autoAlpha: 1 });
function ir(indice) {
if (animando || indice < 0 || indice >= secciones.length) return;
animando = true;
gsap.to(secciones[actual], { autoAlpha: 0, duration: 0.5 });
gsap.fromTo(
secciones[indice],
{ autoAlpha: 0, yPercent: indice > actual ? 12 : -12 },
{
autoAlpha: 1,
yPercent: 0,
duration: 0.6,
ease: "power2.out",
onComplete: () => {
actual = indice;
animando = false;
},
}
);
}
Observer.create({
target: window,
type: "wheel,touch,pointer",
wheelSpeed: -1,
onDown: () => ir(actual - 1),
onUp: () => ir(actual + 1),
tolerance: 12,
preventDefault: true,
});
Ese código funciona pegado tal cual, asumiendo que las secciones estén superpuestas con position: absolute y ocupen la pantalla.
La bandera animando es imprescindible: sin ella, un gesto largo dispara varias transiciones encadenadas y el estado se descuadra.
preventDefault: true cancela el scroll del navegador. Con eso desaparecen la barra de scroll, la navegación con las teclas de página, la búsqueda en página que desplaza hasta el resultado, el anclaje por fragmento de URL, la restauración de posición al volver atrás y el desplazamiento automático al enfocar un elemento con la tecla de tabulación. Si adoptas el patrón, cada una de esas funciones hay que reimplementarla, y en la práctica casi nadie lo hace. Antes de escribirlo, comprueba con el teclado si tu página sigue siendo navegable.
El ciclo de vida
Una instancia se apaga con disable() y se vuelve a encender con enable(). kill() la destruye definitivamente. Observer.getAll() devuelve todas las instancias vivas y Observer.getById() recupera una por su id.
const obs = Observer.create({ /* ... */, id: "navegacion" });
// Apagar temporalmente mientras hay un modal abierto
modal.addEventListener("open", () => obs.disable());
modal.addEventListener("close", () => obs.enable());
// Destruir al desmontar
componente.addEventListener("destroy", () => obs.kill());
Y hay una propiedad estática útil para decidir estrategias: Observer.isTouch vale 0 si el dispositivo solo tiene ratón o puntero, 1 si solo tiene táctil, y 2 si tiene ambos. Es más fiable que las comprobaciones habituales basadas en el ancho de pantalla o en la cadena de agente de usuario.
La lectura superficial de este plugin es que ahorra escribir escuchas para tres APIs distintas, lo cual es cierto y no es lo importante. Lo importante es que cambia el nivel de abstracción al que programas. Los eventos nativos describen mecanismos: una rueda ha girado, un dedo se ha movido, un puntero ha bajado. Los callbacks de Observer describen intenciones: el usuario quiere avanzar, el usuario está arrastrando algo, el usuario ha rectificado la dirección. Y resulta que la intención es exactamente lo que tu interfaz necesita saber, mientras que el mecanismo es un detalle que varía con el dispositivo y del que no quieres depender. Esa traducción de mecanismo a intención tiene una parte tediosa —normalizar unidades, agrupar eventos por frame, distinguir un clic de un arrastre— y una parte genuinamente difícil, que es decidir los umbrales. ¿Cuántos píxeles hacen falta para que un movimiento cuente como gesto? ¿Cuánto tiempo sin moverse significa que ha parado? Esas decisiones no tienen respuesta correcta universal y por eso son parámetros —tolerance, dragMinimum, onStopDelay— en lugar de constantes ocultas: la biblioteca traduce el mecanismo, pero la definición precisa de la intención sigue dependiendo de tu contexto. Y hay una consecuencia práctica que conviene subrayar. Programar en el nivel de la intención hace tu código más portátil pero también más peligroso, porque la misma intención puede llegar de un mecanismo que tú no habías previsto y para el que tu interfaz no tiene sentido. El caso concreto: un usuario de teclado nunca produce un onUp ni un onDown de Observer, porque el teclado no está entre los tipos de entrada que escucha. Si toda tu navegación cuelga de esos dos callbacks, has construido una interfaz que solo existe para quien tiene rueda o dedos. La abstracción unificada es real, pero unifica tres mecanismos, no todos, y los que deja fuera son precisamente los de la gente que más los necesita.
- Monta un Observer que registre por consola el tipo de dispositivo detectado con
Observer.isTouchy los deltas de cada gesto. - Prueba
tolerancea 0, 10 y 60 con un trackpad y con una rueda de ratón. Anota cuál se siente bien con cada uno. - Desactiva
debouncey cuenta cuántas llamadas por frame llegan con un trackpad. - Monta la navegación por secciones del ejemplo y después intenta recorrer la página solo con el teclado. Anota todo lo que se ha roto.
- Añade
ignorepara que un submenú dentro de la zona observada no dispare la navegación.