pin: lo que de verdad le hace al DOM
El espaciador que ScrollTrigger inserta, por qué usa position fixed, qué pasa en cada refresh, y los cinco layouts que rompe pin sin avisar.
pin: true son nueve caracteres que insertan un elemento nuevo en tu DOM, cambian el position de otro, le fijan un ancho y un alto calculados, y añaden centenares de píxeles de relleno a un contenedor. Nada de eso está escrito en tu CSS, nada de eso aparece en tu plantilla, y todo eso puede romper un layout que funcionaba. Quien ha sufrido ScrollTrigger en producción sabe que la mitad de los problemas vienen de aquí, y todos son predecibles en cuanto sabes exactamente qué inserta y por qué.
- Describir el espaciador que ScrollTrigger crea y qué hace con él en cada fase.
- Explicar por qué la fijación usa
position: fixedcuando el scroller es la página y transformaciones cuando no. - Elegir entre
pinSpacingactivado, desactivado y con margen según el contenedor. - Diagnosticar los cinco layouts en los que
pinfalla y aplicar el remedio correcto.
El espaciador, paso a paso
En el instante en que creas una instancia con pin, ScrollTrigger hace esto, sin preguntar:
Envuelve el elemento fijado en un div nuevo con la clase pin-spacer y con un ancho y un alto fijos iguales a los que tenía el elemento. Ese envoltorio existe para mantener abierto el hueco que el elemento va a dejar cuando pase a position: fixed y salga del flujo. Sin él, el contenido de debajo subiría de golpe en el momento de fijar y volvería a bajar al soltar, produciendo el salto más feo del catálogo.
Además, al espaciador se le añade relleno por abajo —o por la derecha en horizontal— igual a la distancia que el elemento va a permanecer fijado. Si la fijación dura 800 píxeles de scroll, el espaciador recibe 800 píxeles de padding-bottom. Eso es lo que hace que, al soltarse la fijación, el contenido de debajo esté justo donde debe: se ha desplazado exactamente lo que el elemento estuvo quieto.
Cuando la barra de scroll entra en el intervalo, el elemento pasa a position: fixed con top, left, width y height calculados. Cuando sale, vuelve a su position original y se le aplica una transformación para colocarlo donde toca.
Y en cada refresh() ocurre lo más importante y lo que más gente ignora: el espaciador se retira del DOM, el elemento original vuelve a su sitio, se toman las medidas con el CSS auténtico, y después el espaciador se vuelve a insertar. Es la única forma de que las medidas no incluyan el relleno artificial del refresco anterior, pero significa que durante ese instante tu árbol de DOM tiene una forma distinta de la que crees.
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.create({
trigger: ".panel",
start: "top top",
end: "+=800",
pin: true,
markers: true,
});
Con ese código en marcha, inspecciona el elemento en el navegador. Verás el div.pin-spacer que no escribiste, y verás cómo su padding-bottom aparece y desaparece al refrescar. Verlo una vez vale más que tres párrafos.
La pregunta obvia es por qué no basta con desplazar el elemento con transform en cada frame, evitando tocar el DOM. La razón es que los navegadores modernos gestionan el repintado del scroll en un hilo distinto del principal, de modo que una transformación calculada en JavaScript llega medio frame tarde y el elemento tiembla. position: fixed lo saca de la ecuación del scroll por completo y lo deja quieto sin que nadie tenga que calcular nada por frame. La excepción es cuando el scroller no es la página: ahí position: fixed se ancla al viewport y no al contenedor, así que en ese caso ScrollTrigger sí usa transformaciones, y el temblor ocasional es el precio.
pinSpacing: tres valores y una excepción
pinSpacing controla el relleno que se añade al espaciador. Admite true, que es el valor por defecto, false y la cadena "margin".
Con false, no se añade relleno: el contenido posterior no se aparta, así que pasa por debajo del elemento fijado mientras dura la fijación. Es lo que quieres para un fondo que se queda quieto mientras el contenido lo sobrevuela, y lo que no quieres nunca para una sección de contenido que se fija.
Con "margin" se usa margen en lugar de relleno. La diferencia importa cuando el elemento tiene fondo o borde y el relleno se vería.
Y hay una excepción que sorprende: si el contenedor es display: flex, pinSpacing vale false por defecto, porque el relleno en un contexto flex no aparta a los hermanos como cabría esperar. Lo mismo pasa con position: absolute: el relleno no empuja nada. En esos casos hay que espaciar a mano, y no es un fallo de ScrollTrigger sino de cómo funciona el modelo de caja.
/* Si tu contenedor es flex y necesitas espaciado, hazlo tu */
.contenedor { display: flex; flex-direction: column; }
.hueco-de-fijacion { height: 800px; flex: 0 0 auto; }
Los cinco layouts que rompe
Un ancestro con transform o con will-change. Es el fallo número uno con diferencia. Cualquier ancestro con una transformación aplicada crea un bloque contenedor nuevo, y un position: fixed dentro de él se fija a ese ancestro, no al viewport. El síntoma es inconfundible: el elemento se fija, pero se mueve con el scroll en vez de quedarse quieto. No es un fallo de ScrollTrigger, es cómo especifica CSS el position: fixed. Y will-change: transform produce el mismo efecto aunque no haya ninguna transformación aplicada.
El remedio correcto es quitar la transformación del ancestro. El remedio de emergencia es pinReparent: true, que reubica el elemento fijado como hijo del body mientras dura la fijación, moviendo los estilos a línea para conservar el aspecto. Funciona, pero es caro, y rompe cualquier regla de CSS que dependa del anidamiento: si tienes .seccion .panel p { color: white } y fijas .panel con reparentado, ese párrafo deja de estar dentro de .seccion durante la fijación y pierde el color.
content-visibility en auto o en hidden. Para el navegador, ese contenido se comporta a efectos de medición como si no existiera, así que ScrollTrigger no puede calcular posiciones. Es una optimización de rendimiento perfectamente razonable que resulta incompatible con este plugin.
Fijar algo dentro de algo ya fijado. No está soportado. Si tu disparador está dentro de un elemento que otra instancia fija, las posiciones se descuadran por la duración de esa fijación, y para eso existe pinnedContainer, que le dice a la instancia interior a qué contenedor mirar para compensar. Pero ojo: pinnedContainer solo sirve para instancias que no fijan; el anidamiento de fijaciones no está soportado.
Animar el propio elemento fijado. ScrollTrigger precalculó su tamaño y su posición; si lo escalas o lo mueves, las medidas dejan de valer. La solución es siempre la misma: fija un contenedor y anima lo que hay dentro.
// MAL: se anima el mismo elemento que se fija
ScrollTrigger.create({ trigger: ".panel", pin: ".panel", end: "+=600" });
gsap.to(".panel", { scale: 1.2, scrollTrigger: { /* ... */ } });
// BIEN: se fija el contenedor, se anima el contenido
ScrollTrigger.create({ trigger: ".panel", pin: true, end: "+=600" });
gsap.to(".panel .contenido", {
scale: 1.2,
scrollTrigger: { trigger: ".panel", start: "top top", end: "+=600", scrub: true },
});
Crear las instancias en desorden. Una fijación alarga el documento, así que todo lo que viene después se desplaza. Si creas primero la instancia de abajo y después la de arriba que fija, la de abajo calculó sus posiciones sin saber que iba a llegar ese alargamiento. La regla es crear siempre en el orden en que las cosas aparecen en la página; si no puedes, refreshPriority con un número más alto adelanta el refresco de una instancia, y ScrollTrigger.sort() reordena la lista entera.
anticipatePin y el destello
Queda un artefacto que aparece solo al hacer scroll rápido y que confunde porque parece un fallo de rendimiento sin serlo. Como el repintado del scroll va por otro hilo, en el instante exacto de fijar puede ser que el navegador ya haya pintado el contenido sin fijar, y se ve una fracción de segundo del elemento en su posición vieja antes de que se clave.
anticipatePin es la respuesta. Es un número, no un booleano: le pide a ScrollTrigger que vigile la velocidad del scroll y adelante la fijación proporcionalmente a ella. anticipatePin: 1 suele bastar. El valor por defecto es 0 porque en la mayoría de las páginas no hace falta y adelantarlo tiene su propio coste visual: si te pasas, el elemento se fija visiblemente antes de llegar a su sitio.
ScrollTrigger.create({
trigger: ".hero",
start: "top top",
end: "+=1200",
pin: true,
anticipatePin: 1,
});
El nombre engaña, y el engaño tiene consecuencias prácticas. Uno esperaría que “fijar” fuese una operación visual: el elemento sigue donde estaba y el navegador lo dibuja quieto. No es eso lo que ocurre. Lo que ocurre es que ScrollTrigger reescribe la estructura del documento —inserta un nodo, saca el elemento del flujo, y añade una cantidad de relleno que existe exclusivamente para que la aritmética del scroll cuadre— y el efecto de quietud es una consecuencia emergente de esa reescritura. Entender esto reordena todo el resto. Explica por qué pin interactúa con flex, con grid, con position: absolute y con las reglas de CSS que dependen del anidamiento: no está en una capa aparte, está en tu layout, compitiendo con tus propias reglas. Explica por qué el orden de creación importa, porque cada fijación cambia la altura total y por tanto las coordenadas de todo lo que viene después: son mutaciones secuenciales sobre un estado compartido, y el orden de las mutaciones secuenciales siempre importa. Y explica la regla que resume todas las demás: nunca animes el elemento que fijas. No es una limitación caprichosa; es que el elemento fijado forma parte de la maquinaria de medición, y mover una pieza de la maquinaria mientras mide da resultados incorrectos por la misma razón por la que no se calibra una báscula estando encima. Si interiorizas que pin es una transformación estructural y no un efecto visual, dejarás de sorprenderte con sus efectos secundarios y empezarás a preverlos: cada vez que vayas a escribirlo, pregúntate primero qué contenedor va a recibir 800 píxeles de relleno y si ese contenedor está preparado para recibirlos.
- Fija un panel y abre el inspector. Localiza el
div.pin-spacer, mira supadding-bottom, y redimensiona la ventana observando cómo desaparece y vuelve. - Pon
transform: translateZ(0)en un ancestro del elemento fijado y observa el fallo. Arréglalo primero quitando la transformación y luego conpinReparent: true, y compara. - Mete el panel fijado en un contenedor
display: flexy explica por qué el contenido posterior no se aparta. - Crea dos instancias en orden inverso al de la página, una de ellas con
pin, y comprueba el desajuste. Arréglalo reordenando y luego conrefreshPriority. - Haz scroll muy rápido sobre una fijación de pantalla completa y busca el destello. Añade
anticipatePin: 1y compara. Sube a3y observa el problema contrario.