Building a Cinematic Scroll Portfolio with GSAP
How I rebuilt this portfolio from a Vite+React static site into a cinematic scroll experience using GSAP ScrollTrigger, Framer Motion, and Lenis — and what I learned about animation architecture along the way.
Building a Cinematic Scroll Portfolio with GSAP
My old portfolio was a Vite + React static site pushed to GitHub Pages. It worked, but it felt like every other developer portfolio — section by section, scroll past a hero, past an about, past projects.
I wanted something that felt like you were watching something, not reading a page. The constraint I set: the entire homepage should be a single animated experience, not a list of sections.
The core idea: one ScrollTrigger, everything pinned
The trick is to give GSAP a single ScrollTrigger with a very long scroll distance (end: "+=10000") and pin the container. Then instead of scrolling between sections, you map scroll progress to a timeline where sections animate in and out.
const tl = gsap.timeline({
scrollTrigger: {
trigger: containerRef.current,
start: 'top top',
end: '+=10000',
pin: true,
scrub: 1.1, // the lag here is intentional — it gives weight
anticipatePin: 1,
},
})
The scrub: 1.1 number matters. scrub: true snaps instantly; scrub: 1 lags by 1 second; scrub: 1.1 gives the animations a physical quality — like the content has inertia.
The card: physical depth with CSS
The central element is a card that rises from below, then expands to fill the screen. The shadow stack is what makes it feel like a physical object:
box-shadow:
0 50px 120px -20px rgba(0,0,0,.98),
0 25px 50px -15px rgba(0,0,0,.85),
inset 0 1px 2px rgba(255,255,255,.17),
inset 0 -2px 4px rgba(0,0,0,.9);
The inset shadows on the top and bottom edge simulate light hitting the card face — the top is slightly lighter, the bottom slightly darker.
Lenis + GSAP: the smooth scroll integration
Raw browser scroll doesn't work well with GSAP ScrollTrigger — there's jitter on some devices. Lenis is a smooth scroll library that intercepts the scroll event and feeds it to GSAP directly:
const lenis = new Lenis()
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => lenis.raf(time * 1000))
gsap.ticker.lagSmoothing(0)
The key is lagSmoothing(0) — without it, GSAP drops frames to compensate for lag, which creates visible stutters during heavy animation.
What I'd do differently
The position: fixed card creates a stacking context problem on mobile Safari. Some transform operations also force GPU compositing in unexpected ways — I had to audit which properties I was animating to avoid layout reflow.
For a simpler implementation: consider using pure CSS animation-timeline with scroll(). It has no JavaScript overhead at all. But for complex sequences with multiple coordinated elements, GSAP is still the right tool.