La página de experiencia de este sitio no es una línea de tiempo: es un recorrido. Una ruta luminosa serpentea por el espacio, con una parada por cada experiencia, y la cámara viaja por ella conforme haces scroll. Al final hay un nodo con un signo de interrogación — la siguiente parada, todavía sin escribir.
Así se construye, y las cuatro cosas que aprendí a golpes.
La arquitectura: un ref, no un estado

El canvas 3D vive fijo detrás del contenido HTML. El scroll no mueve la cámara directamente: escribe un número en un ref, y la cámara lo persigue cada frame.
const progressRef = useRef(0)
ScrollTrigger.create({
trigger: stopsRef.current,
start: 'top 60%',
end: 'bottom 70%',
onUpdate: self => { progressRef.current = self.progress },
})
Y dentro del canvas:
function Rig({ curve, progressRef, stops }) {
const t = useRef(0)
useFrame((state, delta) => {
// damp, no lerp: independiente del framerate
t.current = THREE.MathUtils.damp(t.current, progressRef.current, 2.2, delta)
const p = curve.getPointAt(camU(t.current, stops))
state.camera.position.set(p.x * 0.55, p.y + 1.7, p.z + 6.2)
})
return null
}
Esto es lo más importante de todo el post. Si guardas el progreso del scroll en useState, provocas un render de React por cada píxel scrolleado y la página se arrastra. Un ref no dispara renders: el scroll escribe, useFrame lee, y React ni se entera. La escena entera se anima sin un solo re-render.
El damp (en vez de lerp) es la otra mitad: da suavizado independiente del framerate, así se ve igual a 60 que a 120 Hz.
Sincronizar la ruta con las tarjetas
El error obvio es mapear el scroll linealmente a la curva. Se ve mal: la cámara nunca está exactamente en un nodo cuando su tarjeta está centrada, y todo se siente desfasado.
La solución es pensar en un índice continuo de parada:
const stopK = (p, count) => THREE.MathUtils.clamp(p * count - 0.5, -0.6, count)
Con k = p·n − 0.5, la cámara está sobre el nodo i justo cuando la tarjeta i está centrada, y arranca media parada antes de la primera durante la intro. Números enteros de k son paradas. Desde ahí todo se vuelve fácil de razonar: Math.abs(k - i) < 0.55 es "estás en esta parada", k - i > 0 es "ya la pasaste".
Ese último dato resuelve un problema visual real: las paradas que ya recorriste se ponen enfrente de la que estás leyendo. La solución es desvanecerlas:
function stopFade(t, i) {
const behind = t - i
if (behind <= 0.4) return 1
return THREE.MathUtils.clamp(1 - (behind - 0.4) / 0.9, 0.14, 1)
}
Y aplicarlo recordando la opacidad de diseño de cada material, para que el desvanecido se componga con los alfas del halo y los anillos en vez de aplastarlos:
group.traverse(o => {
const mat = o.material
if (!mat || !('opacity' in mat)) return
if (mat.userData.baseOpacity === undefined) mat.userData.baseOpacity = mat.opacity
mat.opacity = mat.userData.baseOpacity * f
})
Lección 1: nunca escondas UI crítica detrás de un gsap.from()
Esta me costó dos apariciones del mismo bug.
gsap.from(el, { opacity: 0 }) pone el elemento en opacidad 0 y lo anima hasta su valor actual. Si el tween se interrumpe —un remount de React StrictMode, un ScrollTrigger.refresh(), un cambio de dependencias— el elemento se queda en su estado inicial. O sea: invisible. Para siempre.
Me pasó con los chips de filtro de la página de proyectos: desaparecieron por completo. Y casi me pasa con el título de un artículo.
La regla que uso ahora:
- Para UI que debe verse (títulos, navegación, filtros): o no la animes, o anímala sólo con movimiento —
y, nuncaopacity. Un tween congelado deja el elemento 26px abajo; nadie se muere. - Si de verdad necesitas el fade, usa
fromTocon red de seguridad:
gsap.fromTo(bits,
{ y: 28, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.85,
onInterrupt: () => { gsap.set(bits, { clearProps: 'all' }) } })
Lección 2: pin + StrictMode = estado zombi

Quería que el título "volara" hacia ti al hacer scroll, con la sección fijada. En desarrollo, con StrictMode montando dos veces, el ScrollTrigger fijado se quedaba muerto en su estado final: título atorado en escala 6 y opacidad 0, sin forma de recuperarlo salvo recargar.
La solución fue quitar el pin y quedarme sólo con scrub. El efecto se ve casi igual y no hay estado que pueda quedarse atorado:
gsap.timeline({
scrollTrigger: { trigger: '.j-intro', start: 'top top', end: 'bottom 30%', scrub: true },
})
.to('.j-title', { scale: 4.5, opacity: 0, ease: 'power2.in' }, 0)
scrub es una función pura del scroll: donde sea que estés, el estado se recalcula. Eso lo hace inmune a los remounts.
Lección 3: el ticker de GSAP se duerme en pestañas ocultas
Depurando con capturas automatizadas me volví loco: la escena salía negra, los títulos invisibles, todo congelado a media animación. Pensé que había roto algo tres veces.
No era el código. Cuando el navegador considera la pestaña oculta (minimizada, tapada por otra ventana, o capturando en segundo plano), requestAnimationFrame se pausa y GSAP duerme su ticker. Los tweens se quedan a medias, exactamente donde estaban.
Es comportamiento normal del navegador — y es también la razón por la que la lección 1 importa tanto: si tu título depende de un tween que puede quedarse dormido, tu título puede no existir para quien abre tu link en una pestaña de fondo.
Lección 4: el teléfono paga cada píxel
La primera versión en móvil se arrastraba. Tres cambios lo arreglaron:
const phone = useMemo(() => typeof window !== 'undefined' && window.innerWidth < 768, [])
<Canvas dpr={[1, phone ? 1.3 : 1.75]} gl={{ antialias: !phone, alpha: true }}>
<Stars count={phone ? 700 : 1600} ... />
<Sparkles count={phone ? 60 : 140} ... />
Limitar el dpr es lo que más rinde: en una pantalla con densidad 3, un canvas a pantalla completa renderiza nueve veces más píxeles que a densidad 1. Bajarlo a 1.3 no se nota y devuelve el framerate.
El otro culpable no estaba en el 3D: era un backdrop-blur-2xl en una tarjeta a ancho completo encima del canvas. Desenfocar en vivo lo que hay detrás, cada frame, es carísimo. En móvil bajé a backdrop-blur-md con más opacidad de fondo — se ve igual de bien y el scroll queda fluido.
Y una cuarta que no es de rendimiento sino de encuadre: en vertical, el FOV recorta a lo ancho y la ruta se te encima al texto. Lo resolví derivando un factor de "verticalidad" del aspect ratio y usándolo para alejar la cámara, subirla y matar el desplazamiento lateral:
const aspect = state.size.width / Math.max(state.size.height, 1)
const portrait = THREE.MathUtils.clamp((0.9 - aspect) / 0.35, 0, 1)
Un solo número, interpolado, y la misma escena funciona en las dos orientaciones sin ramas duplicadas.
Lo que me llevo
El 3D en una página de portafolio es fácil de arruinar: se vuelve lento, se come el texto, o se siente como un demo en vez de una narrativa. Lo que a mí me funcionó fue tratarlo como fondo con intención: la cámara sigue al contenido, no al revés; el scroll manda; y cada decisión visual se paga con presupuesto de framerate.
Todo el recorrido está en /experience de este mismo sitio, con seis paradas y el signo de interrogación al final.
