4.9 C
New York
Wednesday, November 19, 2025

Find out how to Construct Cinematic 3D Scroll Experiences with GSAP



On this tutorial, we’ll discover two examples on how GSAP can act as a cinematic director for 3D environments. By connecting scroll movement to digital camera paths, lighting, and shader-driven results, we’ll remodel static scenes into fluid, story-like sequences.

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

By the tip, you’ll discover ways to orchestrate 3D composition, easing, and timing to create immersive, film-inspired interactions that reply naturally to person enter.


Free GSAP 3 Express Course


Study trendy internet animation utilizing GSAP 3 with 34 hands-on video classes and sensible initiatives — excellent for all ability ranges.


Test it out

Cylindrical Movement: Shader-Pushed Scroll Dynamics

1. Organising GSAP and customized easings

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

Customized easing curves are important for controlling how the scroll feels — small variations in acceleration dramatically have an effect on the visible 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. Web page structure and ScrollSmoother setup

ScrollSmoother works with a wrapper and a content material container.

The WebGL canvas sits mounted within the background, whereas the graceful content material scrolls above it.

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

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

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

We initialize the smoother:

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

3. Constructing the WebGL scene

We’ll use OGL to arrange a renderer, digital camera, and scene. The cylinder shows an picture atlas (a canvas that stitches a number of photographs horizontally). This enables us to scroll by way of a number of photographs seamlessly by rotating a single mesh.

const renderer = new Renderer({
  canvas: canvasRef.present,
  width:  window.innerWidth,
  top: 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 digital camera = new Digital camera(gl, { fov: 45 })
digital camera.place.set(0, 0, 8)

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

We create the picture atlas dynamically:

const canvas = doc.createElement("canvas")
const ctx = canvas.getContext("2nd")!
canvas.width  = imageConfig.width * photographs.size
canvas.top = imageConfig.top

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

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

Then connect the feel to the cylinder shader:

Cylinder shaders
The cylinder’s shaders deal with the UV mapping of the picture atlas and refined floor colour modulation.

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

uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;

various vec2 vUv;

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

uniform sampler2D tMap;
various vec2 vUv;

void major() {
  vec4 colour = texture2D(tMap, vUv);
  gl_FragColor = colour;
}
const program = new Program(gl, {
  vertex:   cylinderVertex,
  fragment: cylinderFragment,
  uniforms: { tMap: { worth: 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 join scroll to digital camera motion and cylinder rotation utilizing ScrollTrigger.
The container’s top: 500vh offers us sufficient room to house out a number of “photographs.”

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

const tl = gsap.timeline({
  scrollTrigger: {
    set off: containerRef.present,
    begin:   "prime prime",
    finish:     "backside backside",
    scrub:   1
  }
})

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

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

Render loop:

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

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

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

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

5. Typographic overlays

Every title part fades out and in in sync with the scroll, dividing the journey into visible chapters.

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

  const part = 100 / views.size
  const begin   = `${i * part}% prime`
  const finish     = `${(i + 1) * part}% prime`

  gsap.timeline({
    scrollTrigger: {
      set off: containerRef.present,
      begin, finish,
      scrub: 0.8
    }
  })
  .fromTo(textEl, { opacity: 0 }, { opacity: 1, period: 0.2, ease: "cinematicSmooth" })
  .to(textEl,      { opacity: 1, period: 0.6, ease: "none" })
  .to(textEl,      { opacity: 0, period: 0.2, ease: "cinematicSmooth" })
})

6. Particles with rotational inertia

To intensify movement, we’ll add refined line-based particles orbiting the cylinder.
Their opacity will increase when the cylinder spins and fades because it slows down.

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

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

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

Contained in the render loop:

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

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

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

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

  const positions = p.geometry.attributes.place.knowledge 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.place.needsUpdate = true
})

Particle shaders

Every particle line is outlined by a vertex shader that positions factors alongside an arc and a fraction shader that controls colour and opacity.

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

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

uniform vec3 uColor;
uniform float uOpacity;

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

Scene Course: Scroll-Managed Storytelling in Three.js

1. GSAP setup

Register the plugins as soon as on the shopper. We’ll use ScrollTrigger, ScrollSmoother, and SplitText to orchestrate digital camera strikes and textual content 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. Web page structure + ScrollSmoother

We maintain the 3D canvas mounted behind, overlay UI on prime (scroll trace + progress), and wrap the lengthy content material space with #smooth-wrapper / #smooth-content to allow smoothing.

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

{/* Left scroll trace and backside progress bar overlays right here */}

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

Activate smoothing:

ScrollSmoother.create({
  wrapper:  smoothWrapperRef.present!,
  content material:  smoothContentRef.present!,
  {smooth}:   4,
  results:  false,
  smoothTouch: 2,
})

3. The 3D scene (R3F + drei)

We mount a PerspectiveCamera that we will replace each body, add fog for depth, and light-weight the constructing. The constructing mannequin is loaded with useGLTF and frivolously remodeled.

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

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

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

Because the scene takes form, the lighting and scale assist set up depth, however what actually brings it to life is movement. The subsequent step is to attach the scroll to the digital camera itself — remodeling easy enter into cinematic path.

4. Digital camera timeline pushed by scroll

We maintain two mutable refs: cameraAnimRef (digital camera place) 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: {
    set off: containerRef.present,
    begin: "prime prime",
    finish:   "backside backside",
    scrub: true,
    onUpdate: (self) => {
      const progress = self.progress * 100
      setProgressWidth(progress)      // quickSetter for width %
      setProgressText(String(Math.spherical(progress)).padStart(3, "0") + "%")
    }
  }
})

scenePerspectives.forEach((p) => {
  const begin = p.scrollProgress.begin / 100
  const finish   = p.scrollProgress.finish   / 100
  tl.to(cameraAnimRef.present, { x: p.digital camera.x, y: p.digital camera.y, z: p.digital camera.z, period: finish - begin, ease: "none" }, begin)
  tl.to(targetAnimRef.present, { x: p.goal.x, y: p.goal.y, z: p.goal.z, period: finish - begin, ease: "none" }, begin)
})

5. SplitText chapter cues

For every perspective, we place a textual content block in a display screen place derived from its config and animate chars in/out with small staggers.

scenePerspectives.forEach((p, index) => {
  const textEl = textRefs.present[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, { kind: "chars" })
    const subtitleSplit = new SplitText(subtitleEl, { kind: "chars" })
    splitInstancesRef.present.push(titleSplit, subtitleSplit)

    const textTl = gsap.timeline({
      scrollTrigger: {
        set off: containerRef.present,
        begin: `${p.scrollProgress.begin}% prime`,
        finish:   `${p.scrollProgress.finish}% prime`,
        scrub: 0.5,
      }
    })

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

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

6. Overlay UI: scroll trace + progress

A minimal scroll trace on the left and a centered progress bar on the backside. We use gsap.quickSetter to replace width and label effectively from ScrollTrigger’s onUpdate.

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

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

Conclusion

That’s it for this tutorial. You’ve seen how scroll movement can form a scene, how timing and easing can recommend rhythm, and the way digital camera motion can flip a static structure into one thing that feels intentional and cinematic. With GSAP, all of it stays versatile and fluid — each movement turns into simpler to manage and refine.

The methods listed below are simply a place to begin. Attempt shifting the main focus, slowing issues down, or exaggerating transitions to see the way it modifications the temper. Deal with the scroll as a director’s cue, guiding the viewer’s consideration by way of house, gentle, and movement.

In the long run, what makes these experiences participating isn’t the complexity of the code, however the sense of circulate you create. Maintain experimenting, keep curious, and let your subsequent mission inform its story one scroll at a time.



Supply hyperlink

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles