How to Build Cinematic 3D Scroll Experiences with GSAP

0
7


In this tutorial, we’ll explore two examples on how GSAP can act as a cinematic director for 3D environments. By connecting scroll motion to camera paths, lighting, and shader-driven effects, we’ll transform static scenes into fluid, story-like sequences.

The first demo focuses on shader-based depth — a rotating WebGL cylinder surrounded by reactive particles — while the second turns a 3D scene into a scroll-controlled showcase with moving cameras and animated typography.

By the end, you’ll learn how to orchestrate 3D composition, easing, and timing to create immersive, film-inspired interactions that respond naturally to user input.


Free GSAP 3 Express Course


Learn modern web animation using GSAP 3 with 34 hands-on video lessons and practical projects — perfect for all skill levels.


Check it out

Cylindrical Motion: Shader-Driven Scroll Dynamics

1. Setting up GSAP and custom easings

We’ll import and register ScrollTrigger, ScrollSmoother, and CustomEase.

Custom easing curves are essential for controlling how the scroll feels — small variations in acceleration dramatically affect the visual rhythm.

import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { ScrollSmoother } from "gsap/ScrollSmoother"
import { CustomEase } from "gsap/CustomEase"

if (typeof window !== "undefined") {
  gsap.registerPlugin(ScrollTrigger, ScrollSmoother, CustomEase)

  CustomEase.create("cinematicSilk",   "0.45,0.05,0.55,0.95")
  CustomEase.create("cinematicSmooth", "0.25,0.1,0.25,1")
  CustomEase.create("cinematicFlow",   "0.33,0,0.2,1")
  CustomEase.create("cinematicLinear", "0.4,0,0.6,1")
}

2. Page layout and ScrollSmoother setup

ScrollSmoother works with a wrapper and a content container.

The WebGL canvas sits fixed in the background, while the smooth content scrolls above it.

<div className="fixed inset-0 z-0">
  <canvas ref={canvasRef} className="w-full h-full" />
</div>

<div className="fixed inset-0 pointer-events-none z-10 mix-blend-difference">
  {/* overlay text synchronized with scroll */}
</div>

<div ref={smoothWrapperRef} id="smooth-wrapper" className="relative z-20">
  <div ref={smoothContentRef} id="smooth-content">
    <div ref={containerRef} style={{ height: "500vh" }} />
  </div>
</div>

We initialize the smoother:

const smoother = ScrollSmoother.create({
  wrapper:  smoothWrapperRef.current,
  content:  smoothContentRef.current,
  smooth:   4,
  smoothTouch: 0.1,
  effects:  false
})

3. Building the WebGL scene

We’ll use OGL to set up a renderer, camera, and scene. The cylinder displays an image atlas (a canvas that stitches multiple images horizontally). This allows us to scroll through several images seamlessly by rotating a single mesh.

const renderer = new Renderer({
  canvas: canvasRef.current,
  width:  window.innerWidth,
  height: window.innerHeight,
  dpr:    Math.min(window.devicePixelRatio, 2),
  alpha:  true
})
const gl = renderer.gl
gl.clearColor(0.95, 0.95, 0.95, 1)
gl.disable(gl.CULL_FACE)

const camera = new Camera(gl, { fov: 45 })
camera.position.set(0, 0, 8)

const scene = new Transform()
const geometry = createCylinderGeometry(gl, cylinderConfig)

We create the image atlas dynamically:

const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")!
canvas.width  = imageConfig.width * images.length
canvas.height = imageConfig.height

images.forEach((img, i) => {
  drawImageCover(ctx, img, i * imageConfig.width, 0, imageConfig.width, imageConfig.height)
})

const texture = new Texture(gl, { minFilter: gl.LINEAR, magFilter: gl.LINEAR })
texture.image = canvas
texture.needsUpdate = true

Then attach the texture to the cylinder shader:

Cylinder shaders
The cylinder’s shaders handle the UV mapping of the image atlas and subtle surface color modulation.

// cylinderVertex.glsl
attribute vec3 position;
attribute vec2 uv;

uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;

varying vec2 vUv;

void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// cylinderFragment.glsl
precision highp float;

uniform sampler2D tMap;
varying vec2 vUv;

void main() {
  vec4 color = texture2D(tMap, vUv);
  gl_FragColor = color;
}
const program = new Program(gl, {
  vertex:   cylinderVertex,
  fragment: cylinderFragment,
  uniforms: { tMap: { value: texture } },
  cullFace: null
})

const cylinder = new Mesh(gl, { geometry, program })
cylinder.setParent(scene)
cylinder.rotation.y = 0.5

4. Scroll-driven cinematic timeline

Now we’ll connect scroll to camera movement and cylinder rotation using ScrollTrigger.
The container’s height: 500vh gives us enough room to space out multiple “shots.”

const cameraAnim = { x: 0, y: 0, z: 8 }

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: containerRef.current,
    start:   "top top",
    end:     "bottom bottom",
    scrub:   1
  }
})

tl.to(cameraAnim, { x: 0, y: 0, z: 8,   duration: 1,   ease: "cinematicSilk" })
  .to(cameraAnim, { x: 0, y: 5, z: 5,   duration: 1,   ease: "cinematicFlow" })
  .to(cameraAnim, { x: 1.5, y: 2, z: 2, duration: 2,   ease: "cinematicLinear" })
  .to(cameraAnim, { x: 0.5, y: 0, z: 0.8, duration: 3.5, ease: "power1.inOut" })
  .to(cameraAnim, { x: -6, y: -1, z: 8,  duration: 1,   ease: "cinematicSmooth" })

tl.to(cylinder.rotation, { y: "+=28.27", duration: 8.5, ease: "none" }, 0)

Render loop:

const animate = () => {
  requestAnimationFrame(animate)

  camera.position.set(cameraAnim.x, cameraAnim.y, cameraAnim.z)
  camera.lookAt([0, 0, 0])

  const vel = cylinder.rotation.y - lastRotation
  lastRotation = cylinder.rotation.y

  renderer.render({ scene, camera })
}
animate()

5. Typographic overlays

Each title section fades in and out in sync with the scroll, dividing the journey into visual chapters.

perspectives.forEach((perspective, i) => {
  const textEl = textRefs.current[i]
  if (!textEl) return

  const section = 100 / perspectives.length
  const start   = `${i * section}% top`
  const end     = `${(i + 1) * section}% top`

  gsap.timeline({
    scrollTrigger: {
      trigger: containerRef.current,
      start, end,
      scrub: 0.8
    }
  })
  .fromTo(textEl, { opacity: 0 }, { opacity: 1, duration: 0.2, ease: "cinematicSmooth" })
  .to(textEl,      { opacity: 1, duration: 0.6, ease: "none" })
  .to(textEl,      { opacity: 0, duration: 0.2, ease: "cinematicSmooth" })
})

6. Particles with rotational inertia

To accentuate motion, we’ll add subtle line-based particles orbiting the cylinder.
Their opacity increases when the cylinder spins and fades as it slows down.

for (let i = 0; i < particleConfig.numParticles; i++) {
  const { geometry, userData } = createParticleGeometry(gl, particleConfig, i, cylinderConfig.height)

  const program = new Program(gl, {
    vertex:   particleVertex,
    fragment: particleFragment,
    uniforms: { uColor: { value: [0,0,0] }, uOpacity: { value: 0.0 } },
    transparent: true,
    depthTest:   true
  })

  const particle = new Mesh(gl, { geometry, program, mode: gl.LINE_STRIP })
  particle.userData = userData
  particle.setParent(scene)
  particles.push(particle)
}

Inside the render loop:

const inertiaFactor = 0.15
const decayFactor   = 0.92
momentum = momentum * decayFactor + vel * inertiaFactor

const isRotating = Math.abs(vel) > 0.0001
const speed      = Math.abs(vel) * 100

particles.forEach(p => {
  const target = isRotating ? Math.min(speed * 3, 0.95) : 0
  p.program.uniforms.uOpacity.value += (target - p.program.uniforms.uOpacity.value) * 0.15

  const rotationOffset = vel * p.userData.speed * 1.5
  p.userData.baseAngle += rotationOffset

  const positions = p.geometry.attributes.position.data as Float32Array
  for (let j = 0; j <= particleConfig.segments; j++) {
    const t = j / particleConfig.segments
    const angle = p.userData.baseAngle + p.userData.angleSpan * t
    positions[j*3 + 0] = Math.cos(angle) * p.userData.radius
    positions[j*3 + 1] = p.userData.baseY
    positions[j*3 + 2] = Math.sin(angle) * p.userData.radius
  }
  p.geometry.attributes.position.needsUpdate = true
})

Particle shaders

Each particle line is defined by a vertex shader that positions points along an arc and a fragment shader that controls color and opacity.

// particleVertex.glsl
attribute vec3 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;

void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// particleFragment.glsl
precision highp float;

uniform vec3 uColor;
uniform float uOpacity;

void main() {
  gl_FragColor = vec4(uColor, uOpacity);
}

Scene Direction: Scroll-Controlled Storytelling in Three.js

1. GSAP setup

Register the plugins once on the client. We’ll use ScrollTrigger, ScrollSmoother, and SplitText to orchestrate camera moves and text beats.

import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { ScrollSmoother } from "gsap/ScrollSmoother"
import { SplitText } from "gsap/SplitText"

if (typeof window !== "undefined") {
  gsap.registerPlugin(ScrollTrigger, ScrollSmoother, SplitText)
}

2. Page layout + ScrollSmoother

We keep the 3D canvas fixed behind, overlay UI on top (scroll hint + progress), and wrap the long content area with #smooth-wrapper / #smooth-content to enable smoothing.

<div className="fixed inset-0 w-full h-screen z-0">
  <Canvas /* R3F canvas options */> ... </Canvas>
</div>

{/* Left scroll hint and bottom progress bar overlays here */}

<div ref={smoothWrapperRef} id="smooth-wrapper" className="relative z-20">
  <div ref={smoothContentRef} id="smooth-content">
    <div ref={containerRef} style={{ height: "900vh" }} />
  </div>
</div>

Activate smoothing:

ScrollSmoother.create({
  wrapper:  smoothWrapperRef.current!,
  content:  smoothContentRef.current!,
  smooth:   4,
  effects:  false,
  smoothTouch: 2,
})

3. The 3D scene (R3F + drei)

We mount a PerspectiveCamera that we can update every frame, add fog for depth, and light the building. The building model is loaded with useGLTF and lightly transformed.

function CyberpunkBuilding() {
  const { scene } = useGLTF("/cyberpunk_skyscraper.glb")
  useEffect(() => {
    if (scene) {
      scene.scale.set(3, 3, 3)
      scene.position.set(0, 0, 0)
    }
  }, [scene])
  return <primitive object={scene} />
}

function AnimatedCamera({ cameraAnimRef, targetAnimRef }: any) {
  const cameraRef = useRef<any>(null)
  const { set } = useThree()
  useEffect(() => {
    if (cameraRef.current) set({ camera: cameraRef.current })
  }, [set])
  useFrame(() => {
    if (cameraRef.current) {
      cameraRef.current.position.set(
        cameraAnimRef.current.x,
        cameraAnimRef.current.y,
        cameraAnimRef.current.z
      )
      cameraRef.current.lookAt(
        targetAnimRef.current.x,
        targetAnimRef.current.y,
        targetAnimRef.current.z
      )
    }
  })
  return <PerspectiveCamera ref={cameraRef} makeDefault fov={45} near={1} far={1000} position={[0, 5, 10]} />
}

function Scene({ cameraAnimRef, targetAnimRef }: any) {
  const { scene } = useThree()
  useEffect(() => {
    if (scene) {
      const fogColor = new THREE.Color("#0a0a0a")
      scene.fog = new THREE.Fog(fogColor, 12, 28)
      scene.background = new THREE.Color("#0a0a0a")
    }
  }, [scene])
  return (
    <>
      <AnimatedCamera cameraAnimRef={cameraAnimRef} targetAnimRef={targetAnimRef} />
      <ambientLight intensity={0.4} />
      <directionalLight position={[10, 20, 10]} intensity={1.2} castShadow />
      <directionalLight position={[-10, 10, -10]} intensity={0.6} />
      <pointLight position={[0, 50, 20]} intensity={0.8} color="#00ffff" />
      <CyberpunkBuilding />
    </>
  )
}

As the scene takes shape, the lighting and scale help establish depth, but what truly brings it to life is motion. The next step is to connect the scroll to the camera itself — transforming simple input into cinematic direction.

4. Camera timeline driven by scroll

We keep two mutable refs: cameraAnimRef (camera position) and targetAnimRef (look-at). A single timeline maps scene segments (from a scenePerspectives config) to scroll progress.

const cameraAnimRef = useRef({ x: -20, y: 0,  z: 0 })
const targetAnimRef = useRef({ x:   0, y: 15, z: 0 })

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: containerRef.current,
    start: "top top",
    end:   "bottom bottom",
    scrub: true,
    onUpdate: (self) => {
      const progress = self.progress * 100
      setProgressWidth(progress)      // quickSetter for width %
      setProgressText(String(Math.round(progress)).padStart(3, "0") + "%")
    }
  }
})

scenePerspectives.forEach((p) => {
  const start = p.scrollProgress.start / 100
  const end   = p.scrollProgress.end   / 100
  tl.to(cameraAnimRef.current, { x: p.camera.x, y: p.camera.y, z: p.camera.z, duration: end - start, ease: "none" }, start)
  tl.to(targetAnimRef.current, { x: p.target.x, y: p.target.y, z: p.target.z, duration: end - start, ease: "none" }, start)
})

5. SplitText chapter cues

For each perspective, we place a text block in a screen position derived from its config and animate chars in/out with small staggers.

scenePerspectives.forEach((p, index) => {
  const textEl = textRefs.current[index]
  if (!textEl) return
  if (p.hideText) { gsap.set(textEl, { opacity: 0, pointerEvents: "none" }); return }

  const titleEl = textEl.querySelector("h2")
  const subtitleEl = textEl.querySelector("p")
  if (titleEl && subtitleEl) {
    const titleSplit = new SplitText(titleEl, { type: "chars" })
    const subtitleSplit = new SplitText(subtitleEl, { type: "chars" })
    splitInstancesRef.current.push(titleSplit, subtitleSplit)

    const textTl = gsap.timeline({
      scrollTrigger: {
        trigger: containerRef.current,
        start: `${p.scrollProgress.start}% top`,
        end:   `${p.scrollProgress.end}% top`,
        scrub: 0.5,
      }
    })

    const isFirst = index === 0
    const isLast  = index === scenePerspectives.length - 1

    if (isFirst) {
      gsap.set([titleSplit.chars, subtitleSplit.chars], { x: 0, opacity: 1 })
      textTl.to([titleSplit.chars, subtitleSplit.chars], {
        x: 100, opacity: 0, duration: 1, stagger: 0.02, ease: "power2.in"
      })
    } else {
      textTl
        .fromTo([titleSplit.chars, subtitleSplit.chars],
          { x: -100, opacity: 0 },
          {
            x: 0, opacity: 1,
            duration: isLast ? 0.2 : 0.25,
            stagger:  isLast ? 0.01 : 0.02,
            ease: "power2.out"
          }
        )
        .to({}, { duration: isLast ? 1.0 : 0.5 })
        .to([titleSplit.chars, subtitleSplit.chars], {
          x: 100, opacity: 0, duration: 0.25, stagger: 0.02, ease: "power2.in"
        })
    }
  }
})

6. Overlay UI: scroll hint + progress

A minimal scroll hint on the left and a centered progress bar at the bottom. We use gsap.quickSetter to update width and label efficiently from ScrollTrigger’s onUpdate.

const setProgressWidth = gsap.quickSetter(progressBarRef.current, "width", "%")
const setProgressText  = gsap.quickSetter(progressTextRef.current, "textContent")

// ... used inside ScrollTrigger's onUpdate() above

Conclusion

That’s it for this tutorial. You’ve seen how scroll motion can shape a scene, how timing and easing can suggest rhythm, and how camera movement can turn a static layout into something that feels intentional and cinematic. With GSAP, it all stays flexible and fluid — every motion becomes easier to control and refine.

The techniques here are just a starting point. Try shifting the focus, slowing things down, or exaggerating transitions to see how it changes the mood. Treat the scroll as a director’s cue, guiding the viewer’s attention through space, light, and motion.

In the end, what makes these experiences engaging isn’t the complexity of the code, but the sense of flow you create. Keep experimenting, stay curious, and let your next project tell its story one scroll at a time.