wandres.dev
GSAP VI · ScrollTrigger: el modelo

start y end: la sintaxis de dos valores al completo

Cómo se lee la sintaxis de dos posiciones, qué admite cada lado, los valores relativos, las funciones, endTrigger y el envoltorio clamp que evita animaciones a medio scrubbar.

⏱ 20 min

La sintaxis "top center" es la parte de ScrollTrigger que más gente escribe sin entender, y la que produce más ajustes por prueba y error. No es una coordenada ni un porcentaje del recorrido: son dos posiciones que pertenecen a dos objetos distintos, y la configuración se activa cuando ambas coinciden en la pantalla. Una vez que ves esa estructura, deja de haber magia: cualquier valor que puedas imaginar se escribe combinando palabras clave, porcentajes, píxeles y desplazamientos relativos en cualquiera de los dos lados.

🎯 Al terminar esta lección sabrás
  • Leer y escribir cualquier valor de start y end sabiendo a qué objeto se refiere cada mitad.
  • Combinar palabras clave, porcentajes, píxeles y desplazamientos relativos con += y -=.
  • Usar endTrigger, valores numéricos y funciones para casos que la sintaxis de cadena no cubre.
  • Aplicar clamp() para evitar que una animación arranque ya empezada.

Dos posiciones que tienen que encontrarse

El valor es una cadena con dos partes separadas por un espacio. La primera describe un punto del elemento disparador. La segunda describe un punto del scroller, que por defecto es el viewport. La instancia se activa en el instante en que esos dos puntos coinciden.

Así, start: "top center" se lee “cuando el borde superior del trigger llegue al centro del viewport”. Y end: "bottom 20%" se lee “cuando el borde inferior del trigger llegue al 20% desde arriba del viewport”. El orden nunca cambia: primero el elemento, después la pantalla.

En cada mitad puedes usar cualquiera de estos tres tipos de valor:

Tipo Ejemplos Significado
Palabra clave top, center, bottom Extremo o centro. Con horizontal: true son left, center, right
Porcentaje 80%, 25% Medido desde el borde superior o izquierdo del elemento o del scroller
Píxeles 100px, -40px Medido desde el borde superior o izquierdo

Los porcentajes son la fuente número uno de confusión, porque en la primera mitad se miden sobre la altura del elemento y en la segunda sobre la altura del viewport. "80% 80%" significa “cuando el punto situado al 80% de la altura del trigger llegue al 80% de la altura de la pantalla”, que son dos magnitudes completamente distintas que dan la casualidad de compartir número.

// Los cuatro valores que cubren el 90% de los casos reales
gsap.from(".tarjeta", {
  autoAlpha: 0,
  y: 40,
  scrollTrigger: {
    trigger: ".tarjeta",
    start: "top 85%",   // el borde de arriba llega al 85% de la pantalla
    end: "bottom 15%",  // el borde de abajo llega al 15% de la pantalla
  },
});

Conviene saber los valores por defecto de memoria, porque explican comportamientos que parecen inexplicables. start vale "top bottom", es decir, en cuanto asoma un píxel del trigger por abajo. end vale "bottom top", es decir, cuando el trigger ha desaparecido del todo por arriba. Y hay una excepción importante: si pones pin: true, el start por defecto pasa a ser "top top", que es lo único razonable para algo que se va a quedar clavado en pantalla.

Desplazamientos relativos y valores absolutos

Cualquiera de las dos mitades admite un desplazamiento con += o -= pegado detrás, sin espacios. Eso permite expresar cosas que las palabras clave solas no alcanzan.

start: "top bottom-=100px"  // 100px antes de que el trigger asome del todo
start: "top+=50 center"     // 50px por debajo del borde superior del trigger
start: "center center+=10%" // un pelin por debajo del centro de la pantalla

El valor de end admite además una forma que start no tiene: un relativo suelto, sin par. end: "+=800" significa “ochocientos píxeles de scroll más allá de donde esté el start”, sea cual sea. Es la forma más honesta de expresar la duración de un scrub, porque desacopla el final del tamaño del elemento. Y end: "+=100%" significa “la altura del scroller más allá del start”, que es la manera portátil de decir “una pantalla entera”.

También existe la palabra clave "max", que representa la posición máxima de scroll del documento, útil para barras de progreso globales.

Ambos aceptan además un número puro, que se interpreta como una posición absoluta de la barra de scroll en píxeles. start: 1200 significa literalmente “cuando la barra esté en 1200”. No hay elemento de por medio, ni siquiera hace falta declarar trigger. Es la forma que usas cuando ya has calculado la posición tú mismo.

// Barra de progreso de toda la pagina, sin elemento disparador
gsap.to(".barra-progreso", {
  scaleX: 1,
  ease: "none",
  scrollTrigger: { start: 0, end: "max", scrub: true },
});

Ese ejemplo requiere que .barra-progreso tenga transform-origin: left y transform: scaleX(0) en el CSS, porque gsap.to anima hacia el valor final desde el estado actual.

endTrigger y las funciones

Por defecto, end se mide sobre el mismo elemento que start. Cuando el final depende de otro elemento, existe endTrigger, y solo hace falta declararlo si es distinto del trigger.

// Un sumario que se mantiene activo desde su titulo hasta el pie del articulo
ScrollTrigger.create({
  trigger: ".articulo h1",
  endTrigger: ".articulo footer",
  start: "top 20%",
  end: "bottom 80%",
  toggleClass: { targets: ".sumario", className: "visible" },
});

Cuando ni siquiera eso llega, las dos propiedades aceptan una función. Se llama en cada refresh() y debe devolver una cadena o un número. Como recibe la propia instancia como único parámetro, puedes construir valores en función del entorno, del tamaño de la ventana o incluso de la instancia anterior.

ScrollTrigger.create({
  trigger: ".galeria",
  start: "top top",
  // La distancia depende del ancho real del carril, que cambia con el resize
  end: () => "+=" + document.querySelector(".carril").scrollWidth,
  scrub: 1,
  pin: true,
});

El detalle que hace útiles a las funciones es que se reevalúan en cada refresh. Un valor calculado en línea, como end: "+=" + carril.scrollWidth, se evalúa una vez al crear la instancia y se queda congelado para siempre; la misma expresión dentro de una función se recalcula cuando la ventana cambia de tamaño. Es la diferencia entre un layout que sobrevive al giro de un móvil y uno que no.

💡
Encadenar instancias con previous

Una instancia puede consultar a su vecina en el orden de refresco con self.previous() y self.next(). Eso permite construir secuencias donde cada bloque empieza exactamente donde acabó el anterior, sin repetir aritmética: start: (self) => self.previous().end. Es frágil si cambias el orden de creación, así que úsalo solo cuando la secuencia sea realmente una cadena.

clamp y el problema del contenido que ya está en pantalla

Hay un fallo que aparece en cuanto pruebas la página en una pantalla grande y que en el portátil del desarrollador no se ve nunca. Si el start calculado de un elemento cae en una posición de scroll negativa —porque el elemento ya está visible cuando la página carga, con la barra en cero— la instancia arranca con un progress mayor que cero. En modo evento eso no molesta. En modo scrub significa que el elemento aparece con la animación a medio hacer: un titular ya desplazado, una imagen ya escalada, un opacity a 0.4 que nunca llega a 1.

Lo mismo pasa por abajo: un elemento al final del documento cuyo end calculado cae más allá del scroll máximo nunca alcanza el progress 1, y su animación se queda a medio camino para siempre.

La solución es envolver el valor en clamp(), que le pide a ScrollTrigger que recorte la posición calculada al rango real del documento, entre cero y el scroll máximo.

gsap.from(".hero-imagen", {
  scale: 1.3,
  ease: "none",
  scrollTrigger: {
    trigger: ".hero-imagen",
    start: "clamp(top bottom)",
    end: "clamp(bottom top)",
    scrub: true,
  },
});

Dentro del clamp() cabe cualquier valor de los que has visto: "clamp(top 80%)", "clamp(20px 50%)". No es una función de CSS aunque se le parezca; es una marca que ScrollTrigger reconoce al analizar la cadena.

La sintaxis de dos posiciones es una restricción declarativa disfrazada de azúcar sintáctico

Hay una pregunta que casi nadie se hace: por qué inventarse una minisintaxis en vez de aceptar simplemente dos números. La respuesta es que dos números no sobreviven a un resize, y esa es toda la historia. Cualquier valor que escribas de la forma "top 85%" es en realidad una restricción geométrica —una ecuación con dos incógnitas que dependen del layout— y no un valor. ScrollTrigger la resuelve para obtener un píxel concreto, y vuelve a resolverla cada vez que el layout cambia. Si el API aceptase solo píxeles, el trabajo de reevaluar esa ecuación en cada resize recaería sobre ti, y lo harías mal, porque implicaría llamar a getBoundingClientRect sobre el elemento y sobre el scroller en el momento adecuado del ciclo del navegador. Al meter la ecuación dentro de la configuración, ScrollTrigger puede reevaluarla en un único punto centralizado —el refresh()— y hacerlo para todas las instancias a la vez, en un solo lote de lecturas de geometría, evitando la alternancia de lecturas y escrituras que provoca reflows en cascada. Esa es la razón profunda de que las funciones que devuelven valores sean tan potentes: no son un escape para casos raros, son la extensión natural del mismo principio, y son el único sitio de todo el API donde puedes meter tu propio cálculo dentro del punto de reevaluación centralizado en lugar de fuera. Si alguna vez te sorprendes escribiendo window.addEventListener("resize", ...) para recalcular un start, párate: ese cálculo pertenece dentro de una función de start, y desde ahí funcionará mejor y en el orden correcto.

⚔️ Calibrar el ojo
  1. Activa los marcadores en una sección y escribe cinco valores distintos de start que produzcan exactamente la misma posición. Al menos uno tiene que usar += y otro un porcentaje.
  2. Monta un scrub con end: "bottom top" y otro con end: "+=100%" sobre el mismo elemento. Cambia la altura del elemento y explica por qué solo uno de los dos se mueve.
  3. Coloca un elemento con scrub en la primera pantalla, sin clamp(), y observa con qué progress arranca. Añade el clamp() y compáralo.
  4. Sustituye un end calculado en línea por una función equivalente y comprueba la diferencia al redimensionar la ventana.
  5. Construye una barra de progreso global de tres líneas usando start: 0 y end: "max".