Three.js and GSAP: the two libraries behind almost every award-site hero
September 17, 2026
Run enough reverse_engineer_webgl and
reverse_engineer_animations calls against Awwwards-caliber sites and
a pattern shows up fast: it's rarely one exotic framework doing everything.
It's almost always Three.js handling the scene, and GSAP — usually with
ScrollTrigger — deciding when anything in that scene is allowed to
move.
They're not competing for the same job
Three.js draws pixels: geometry, materials, a camera, a render loop. It has no opinion about scroll position, easing curves, or sequencing — left alone it will happily render the same static frame forever. GSAP has no opinion about WebGL at all; it interpolates numbers over time against whatever curve you give it. The pairing works because neither one tries to do the other's job: GSAP owns when and how fast, Three.js owns what gets drawn. The bridge between them is almost always a handful of shader uniforms or object properties GSAP is allowed to write to every frame.
The pattern in practice
Scrubbed camera or uniform, driven by scroll, not autoplay — the exact shape we see over and over in captured heroes:
const uniforms = { uProgress: { value: 0 } };
gsap.to(uniforms.uProgress, {
value: 1,
ease: "none",
scrollTrigger: {
trigger: ".hero",
start: "top top",
end: "+=150%",
scrub: 1.2,
},
});
function frame() {
material.uniforms.uProgress.value = uniforms.uProgress.value;
renderer.render(scene, camera);
requestAnimationFrame(frame);
}
scrub: 1.2 is doing more work than it looks like — it adds
a small lag between scroll position and animation value, which is the
difference between motion that feels physically connected to the scrollbar and
motion that feels like it's fighting it. ease: "none" on the
ScrollTrigger tween itself matters too: the easing captured sites use lives in
the shader or the scrub value, not stacked on top of it.
Where it actually breaks
- Two clocks disagreeing. Lenis (or any smooth-scroll
library) and ScrollTrigger both think they own scroll position. Skip
Lenis's own rAF loop and let ScrollTrigger's
scrollerProxydrive it, or the scrub value visibly stutters against the actual scroll. - Frame budget. A render loop calling
renderer.render()unconditionally runs at full rate even when nothing on screen changed. Pausing viaIntersectionObserveronce a canvas scrolls out of view is the single highest-leverage performance fix we see missing. - Tone mapping remapping your exact hex values. A lit PBR pipeline with filmic tone mapping will not reproduce a flat brand color faithfully. If a shader needs to hit an exact hex, turn tone mapping off for that material.
If you want the exact curve, scrub value, and shader parameters for a
specific site instead of the general pattern, that's what
reverse_engineer_animations and reverse_engineer_webgl
are for — run them together against the same URL and you get both halves
of the bridge.