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.
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.


