Crafting Scroll Based Animations in Three.js

Having an experience composed of only WebGL is great, but sometimes, you’ll want the experience to be part of a classic website.

The experience can be in the background to add some beauty to the page, but then, you’ll want that experience to integrate properly with the HTML content.

In this tutorial, we will:

  • learn how to use Three.js as a background of a classic HTML page
  • make the camera translate to follow the scroll
  • discover some tricks to make the scrolling more immersive
  • add a cool parallax effect based on the cursor position
  • trigger some animations when arriving at the corresponding sections
See the live version

This tutorial is part of the 39 lessons available in the Three.js Journey course.

Three.js Journey is the ultimate course to learn WebGL with Three.js. Once you’ve subscribed, you get access to 45 hours of videos also available as text version. First, you’ll start with the basics like the reasons to use Three.js and how to setup a simple scene. Then, you’ll start animating it, creating cool environments, interacting with it, creating your own models in Blender. To finish, you will learn advanced techniques like physics, shaders, realistic renders, code structuring, baking, etc.

As a member of the Three.js Journey community, you will also get access to a members-only Discord server.

Use the code CODROPS1 for a 20% discount.

Starter

This tutorial is intended for beginners but with some basic knowledge of Three.js.

Installation

For this tutorial, a starter.zip file is provided.

  • Download the starter https://threejs-journey.com/resources/codrops/threejs-scroll-based-animation/starter.zip
  • Unzip it
  • Open the index.html file in your favorite browser

You should see a red cube at the center with “My Portfolio” written on it:

The libraries are loaded as plain <script> to keep things simple and accessible for everyone:

  • Three.js in version 0.136.0
  • GSAP in version 3.9.1

For specific techniques like Three.js controls or texture loading, you are going to need a development server, but we are not going to use those here.

Setup

We already have a basic Three.js setup.

Here’s a quick explaination of what each part of the setup does, but if you want to learn more, everything is explained in the Three.js Journey course:

index.html

<canvas class="webgl"></canvas>

Creates a <canvas> in which we are going to draw the WebGL renders.

<section class="section">
    <h1>My Portfolio</h1>
</section>
<section class="section">
    <h2>My projects</h2>
</section>
<section class="section">
    <h2>Contact me</h2>
</section>

Creates some sections with a simple title in them. You can add whatever you want in these.

<script src="./three.min.js"></script>
<script src="./gsap.min.js"></script>
<script src="./script.js"></script>

Loads the Three.js library, the GSAP library, and to finish, our JavaScript file.

style.css

*
{
    margin: 0;
    padding: 0;
}

Resets any margin or padding.

.webgl
{
    position: fixed;
    top: 0;
    left: 0;
}

Makes the WebGL <canvas> fit the viewport and stay fixed while scrolling.

.section
{
    display: flex;
    align-items: center;
    height: 100vh;
    position: relative;
    font-family: 'Cabin', sans-serif;
    color: #ffeded;
    text-transform: uppercase;
    font-size: 7vmin;
    padding-left: 10%;
    padding-right: 10%;
}

section:nth-child(odd)
{
    justify-content: flex-end;
}

Centers the sections. Also centers the text vertically and aligns it on the right for one out of two sections.

script.js

/**
 * Base
 */
// Canvas
const canvas = document.querySelector('canvas.webgl')

// Scene
const scene = new THREE.Scene()

Retrieves the canvas from the HTML and create a Three.js Scene.

/**
 * Test cube
 */
const cube = new THREE.Mesh(
    new THREE.BoxGeometry(1, 1, 1),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)
scene.add(cube)

Creates the red cube that we can see at the center. We are going to remove it shortly.

/**
 * Sizes
 */
const sizes = {
    width: window.innerWidth,
    height: window.innerHeight
}

window.addEventListener('resize', () =>
{
    // Update sizes
    sizes.width = window.innerWidth
    sizes.height = window.innerHeight

    // Update camera
    camera.aspect = sizes.width / sizes.height
    camera.updateProjectionMatrix()

    // Update renderer
    renderer.setSize(sizes.width, sizes.height)
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
})

Saves the size of the viewport in a sizes variable, updates that variable when a resize event occurs and updates the camera and renderer at the same time (more about these two right after).

/**
 * Camera
 */
// Base camera
const camera = new THREE.PerspectiveCamera(35, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 6
scene.add(camera)

Creates a PerspectiveCamera and moves it backward on the positive z axis.

/**
 * Renderer
 */
const renderer = new THREE.WebGLRenderer({
    canvas: canvas
})
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

Creates the WebGLRenderer that will render the scene seen from the camera and updates its size and pixel ratio with a maximum of 2 to prevent performance issues.

/**
 * Animate
 */
const clock = new THREE.Clock()

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Render
    renderer.render(scene, camera)

    // Call tick again on the next frame
    window.requestAnimationFrame(tick)
}

tick()

Starts a loop with a classic requestAnimationFrame to call the tick function on each frame and animates our experience. In that tick function, we do a render of the scene from the camera on each frame.

The Clock lets us retrieve the elapsed time that we save in the elapsedTime variable for later use.

HTML Scroll

Fix the elastic scroll

In some environments, you might notice that, if you scroll too far, you get a kind of elastic animation when the page goes beyond the limit:

While this is a cool feature, by default, the back of the page is white and doesn’t match our experience.

We want to keep that elastic effect for those who have it, but make the white parts the same color as the renderer.

We could have set the background-color of the page to the same color as the clearColor of the renderer. But instead, we are going to make the clearColor transparent and only set the background-color on the page so that the background color is set at one place only.

To do that, in /script.js, you need to set the alpha property to true on the WebGLRenderer:

const renderer = new THREE.WebGLRenderer({
    canvas: canvas,
    alpha: true
})

By default, the clear alpha value is 0 which is why we didn’t have to set it ourselves. Telling the renderer to handle alpha is enough. But if you want to change that value, you can do it with setClearAlpha:

renderer.setClearAlpha(0)

We can now see the back of the page which is white:

In /style.css, add a background-color to the html in CSS:

html
{
    background: #1e1a20;
}

We get a nice uniform background color and the elastic scroll isn’t an issue anymore:

Objects

We are going to create an object for each section to illustrate each of them.

To keep things simple, we will use Three.js primitives, but you can create whatever you want or even import custom models into the scene.

In /script.js, remove the code for the cube. In its place, create three Meshes using a TorusGeometry, a ConeGeometry and a TorusKnotGeometry:

/**
 * Objects
 */
// Meshes
const mesh1 = new THREE.Mesh(
    new THREE.TorusGeometry(1, 0.4, 16, 60),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)
const mesh2 = new THREE.Mesh(
    new THREE.ConeGeometry(1, 2, 32),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)
const mesh3 = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16),
    new THREE.MeshBasicMaterial({ color: '#ff0000' })
)

scene.add(mesh1, mesh2, mesh3)

All the objects should be on top of each other (we will fix that later):

In order to keep things simple, our code will be a bit redundant. But don’t hesitate to use arrays or other code structuring solutions if you have more sections.

Material

Base material

We are going to use the MeshToonMaterial for the objects and are going to create one instance of the material and use it for all three Meshes.

When creating the MeshToonMaterial, use '#ffeded' for the color property and apply it to all 3 Meshes:

// Material
const material = new THREE.MeshToonMaterial({ color: '#ffeded' })

// Meshes
const mesh1 = new THREE.Mesh(
    new THREE.TorusGeometry(1, 0.4, 16, 60),
    material
)
const mesh2 = new THREE.Mesh(
    new THREE.ConeGeometry(1, 2, 32),
    material
)
const mesh3 = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16),
    material
)

scene.add(mesh1, mesh2, mesh3)

Unfortunately, it seems that the objects are now black:

The reason is that the MeshToonMaterial is one of the Three.js materials that appears only when there is light.

Light

Add one DirectionalLight to the scene:

/**
 * Lights
 */
const directionalLight = new THREE.DirectionalLight('#ffffff', 1)
directionalLight.position.set(1, 1, 0)
scene.add(directionalLight)

You should now see your objects:

Position

By default, in Three.js, the field of view is vertical. This means that if you put one object on the top part of the render and one object on the bottom part of the render and then you resize the window, you’ll notice that the objects stay put at the top and at the bottom.

To illustrate this, temporarily add this code:

mesh1.position.y = 2
mesh1.scale.set(0.5, 0.5, 0.5)

mesh2.visible = false

mesh3.position.y = - 2
mesh3.scale.set(0.5, 0.5, 0.5)

The torus stays at the top and the torus knot stays at the bottom:

When you’re done, remove the code above.

This is good because it means that we only need to make sure that each object is far enough away from the other on the y axis, so that we don’t see them together.

Create an objectsDistance variable and choose a random value like 2:

const objectsDistance = 2

Use that variable to position the meshes on the y axis. The values must be negative so that the objects go down:

mesh1.position.y = - objectsDistance * 0
mesh2.position.y = - objectsDistance * 1
mesh3.position.y = - objectsDistance * 2

Increase the objectsDistance until the objects are far enough apart. A good amount should be 4, but you can go back to change that value later.

const objectsDistance = 4

Now, we can only see the first object:

The two others will be below. We will position them horizontally once we move the camera with the scroll and they appear again.

The objectsDistance will get handy a bit later, which is why we saved the value in a variable.

Permanent rotation

To give more life to the experience, we are going to add a permanent rotation to the objects.

First, add the objects to a sectionMeshes array:

const sectionMeshes = [ mesh1, mesh2, mesh3 ]

Then, in the tick function, loop through the sectionMeshes array and apply a slow rotation by using the elapsedTime already available:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()

    // Animate meshes
    for(const mesh of sectionMeshes)
    {
        mesh.rotation.x = elapsedTime * 0.1
        mesh.rotation.y = elapsedTime * 0.12
    }

    // ...
}

All the meshes (though we can see only one here) should slowly rotate:

Camera

Scroll

It’s time to make the camera move with the scroll.

First, we need to retrieve the scroll value. This can be done with the window.scrollY property.

Create a scrollY variable and assign it window.scrollY:

/**
 * Scroll
 */
let scrollY = window.scrollY

But then, we need to update that value when the user scrolls. To do that, listen to the 'scroll' event on window:

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY

    console.log(scrollY)
})

You should see the scroll value in the logs. Remove the console.log.

In the tick function, use scrollY to make the camera move (before doing the render):

const tick = () =>
{
    // ...

    // Animate camera
    camera.position.y = scrollY

    // ...
}

Not quite right yet:

The camera is way too sensitive and going in the wrong direction. We need to work a little on that value.

scrollY is positive when scrolling down, but the camera should go down on the y axis. Let’s invert the value:

camera.position.y = - scrollY

Better, but still too sensitive:

scrollY contains the amount of pixels that have been scrolled. If we scroll 1000 pixels (which is not that much), the camera will go down of 1000 units in the scene (which is a lot).

Each section has exactly the same size as the viewport. This means that when we scroll the distance of one viewport height, the camera should reach the next object.

To do that, we need to divide scrollY by the height of the viewport which is sizes.height:

camera.position.y = - scrollY / sizes.height

The camera is now going down of 1 unit for each section scrolled. But the objects are currently separated by 4 units which is the objectsDistance variable:

We need to multiply the value by objectsDistance:

camera.position.y = - scrollY / sizes.height * objectsDistance

To put it in a nutshell, if the user scrolls down one section, then the camera will move down to the next object:

Position object horizontally

Now is a good time to position the objects left and right to match the titles:

mesh1.position.x = 2
mesh2.position.x = - 2
mesh3.position.x = 2

Parallax

We call parallax the action of seeing one object through different observation points. This is done naturally by our eyes and it’s how we feel the depth of things.

To make our experience more immersive, we are going to apply this parallax effect by making the camera move horizontally and vertically according to the mouse movements. It will create a natural interaction, and help the user feel the depth.

Cursor

First, we need to retrieve the cursor position.

To do that, create a cursor object with x and y properties:

/**
 * Cursor
 */
const cursor = {}
cursor.x = 0
cursor.y = 0

Then, listen to the mousemove event on window and update those values:

window.addEventListener('mousemove', (event) =>
{
    cursor.x = event.clientX
    cursor.y = event.clientY

    console.log(cursor)
})

You should get the pixel positions of the cursor in the console:

While we could use those values directly, it’s always better to adapt them to the context.

First, the amplitude depends on the size of the viewport and users with different screen resolutions will have different results. We can normalize the value (from 0 to 1) by dividing them by the size of the viewport:

window.addEventListener('mousemove', (event) =>
{
    cursor.x = event.clientX / sizes.width
    cursor.y = event.clientY / sizes.height

    console.log(cursor)
})

While this is better already, we can do even more.

We know that the camera will be able to go as much on the left as on the right. This is why, instead of a value going from 0 to 1 it’s better to have a value going from -0.5 to 0.5.

To do that, subtract 0.5:

window.addEventListener('mousemove', (event) =>
{
    cursor.x = event.clientX / sizes.width - 0.5
    cursor.y = event.clientY / sizes.height - 0.5

    console.log(cursor)
})

Here is a clean value adapted to the context:

Remove the console.log.

We can now use the cursor values in the tick function. Create a parallaxX and a parallaxY variable and put the cursor.x and cursor.y in them:

const tick = () =>
{
    // ...

    // Animate camera
    camera.position.y = - scrollY / sizes.height * objectsDistance

    const parallaxX = cursor.x
    const parallaxY = cursor.y
    camera.position.x = parallaxX
    camera.position.y = parallaxY

    // ...
}

Unfortunately, we have two issues.

The x and y axes don’t seem synchronized in terms of direction. And, the camera scroll doesn’t work anymore:

Let’s fix the first issue. When we move the cursor to the left, the camera seems to go to the left. Same thing for the right. But when we move the cursor up, the camera seems to move down and the opposite when moving the cursor down.

To fix that weird feeling, invert the cursor.y:

    const parallaxX = cursor.x
    const parallaxY = - cursor.y
    camera.position.x = parallaxX
    camera.position.y = parallaxY

For the second issue, the problem is that we update the camera.position.y twice and the second one will replace the first one.

To fix that, we are going to put the camera in a Group and apply the parallax on the group and not the camera itself.

Right before instantiating the camera, create the Group, add it to the scene and add the camera to the Group:

/**
 * Camera
 */
// Group
const cameraGroup = new THREE.Group()
scene.add(cameraGroup)

// Base camera
const camera = new THREE.PerspectiveCamera(35, sizes.width / sizes.height, 0.1, 100)
camera.position.z = 6
cameraGroup.add(camera)

This shouldn’t change the result, but now, the camera is inside a group.

In the tick function, instead of applying the parallax on the camera, apply it on the cameraGroup:

const tick = () =>
{
    // ...

    // Animate camera
    camera.position.y = - scrollY / sizes.height * objectsDistance

    const parallaxX = cursor.x
    const parallaxY = - cursor.y
    
    cameraGroup.position.x = parallaxX
    cameraGroup.position.y = parallaxY

    // ...
}

The scroll animation and parallax animation are now mixed together nicely:

But we can do even better.

Easing

The parallax animation is a good start, but it feels a bit too mechanic. Having such a linear animation is impossible in real life for a number of reasons: the camera has weight, there is friction with the air and surfaces, muscles can’t make such a linear movement, etc. This is why the movement feels a bit wrong. We are going to add some “easing” (also called “smoothing” or “lerping”) and we are going to use a well-known formula.

The idea behind the formula is that, on each frame, instead of moving the camera straight to the target, we are going to move it (let’s say) a 10th closer to the destination. Then, on the next frame, another 10th closer. Then, on the next frame, another 10th closer.

On each frame, the camera will get a little closer to the destination. But, the closer it gets, the slower it moves because it’s always a 10th of the actual position toward the target position.

First, we need to change the = to += because we are adding to the actual position:

    cameraGroup.position.x += parallaxX
    cameraGroup.position.y += parallaxY

Then, we need to calculate the distance from the actual position to the destination:

    cameraGroup.position.x += (parallaxX - cameraGroup.position.x)
    cameraGroup.position.y += (parallaxY - cameraGroup.position.y)

Finally, we only want a 10th of that distance:

    cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 0.1
    cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 0.1

The animation feels a lot smoother:

But there is still a problem that some of you might have noticed.

If you test the experience on a high frequency screen, the tick function will be called more often and the camera will move faster toward the target. While this is not a big issue, it’s not accurate and it’s preferable to have the same result across devices as much as possible.

To fix that, we need to use the time spent between each frame.

Right after instantiating the Clock, create a previousTime variable:

const clock = new THREE.Clock()
let previousTime = 0

At the beginning of the tick function, right after setting the elapsedTime, calculate the deltaTime by subtracting the previousTime from the elapsedTime:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - previousTime

    // ...
}

And then, update the previousTime to be used on the next frame:

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - previousTime
    previousTime = elapsedTime

    console.log(deltaTime)

    // ...
}

You now have the time spent between the current frame and the previous frame in seconds. For high frequency screens, the value will be smaller because less time was needed.

We can now use that deltaTime on the parallax, but, because the deltaTime is in seconds, the value will be very small (around 0.016 for most common screens running at 60fps). Consequently, the effect will be very slow.

To fix that, we can change 0.1 to something like 5:

    cameraGroup.position.x += (parallaxX - cameraGroup.position.x) * 5 * deltaTime
    cameraGroup.position.y += (parallaxY - cameraGroup.position.y) * 5 * deltaTime

We now have a nice easing that will feel the same across different screen frequencies:

Finally, now that we have the animation set properly, we can lower the amplitude of the effect:

    const parallaxX = cursor.x * 0.5
    const parallaxY = - cursor.y * 0.5

Particles

A good way to make the experience more immersive and to help the user feel the depth is to add particles.

We are going to create very simple square particles and spread them around the scene.

Because we need to position the particles ourselves, we are going to create a custom BufferGeometry.

Create a particlesCount variable and a positions variable using a Float32Array:

/**
 * Particles
 */
// Geometry
const particlesCount = 200
const positions = new Float32Array(particlesCount * 3)

Create a loop and add random coordinates to the positions array:

for(let i = 0; i < particlesCount; i++)
{
    positions[i * 3 + 0] = Math.random()
    positions[i * 3 + 1] = Math.random()
    positions[i * 3 + 2] = Math.random()
}

We will change the positions later, but for now, let’s keep things simple and make sure that our geometry is working.

Instantiate the BufferGeometry and set the position attribute:

const particlesGeometry = new THREE.BufferGeometry()
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))

Create the material using PointsMaterial:

// Material
const particlesMaterial = new THREE.PointsMaterial({
    color: '#ffeded',
    sizeAttenuation: true,
    size: 0.03
})

Create the particles using Points:

// Points
const particles = new THREE.Points(particlesGeometry, particlesMaterial)
scene.add(particles)

You should get a bunch of particles spread around in a cube:

We can now position the particles on the three axes.

For the x (horizontal) and z (depth), we can use random values that can be as much positive as they are negative:

for(let i = 0; i < particlesCount; i++)
{
    positions[i * 3 + 0] = (Math.random() - 0.5) * 10
    positions[i * 3 + 1] = Math.random()
    positions[i * 3 + 2] = (Math.random() - 0.5) * 10
}

For the y (vertical) it’s a bit more tricky. We need to make the particles start high enough and then spread far enough below so that we reach the end with the scroll.

To do that, we can use the objectsDistance variable and multiply by the number of objects which is the length of the sectionMeshes array:

for(let i = 0; i < particlesCount; i++)
{
    positions[i * 3 + 0] = (Math.random() - 0.5) * 10
    positions[i * 3 + 1] = objectsDistance * 0.5 - Math.random() * objectsDistance * sectionMeshes.length
    positions[i * 3 + 2] = (Math.random() - 0.5) * 10
}

That’s all for the particles, but you can improve them with random sizes, random alpha. And, we can even animate them.

Triggered rotations

As a final feature and to make the exercise just a bit harder, we are going to make the objects do a little spin when we arrive at the corresponding section in addition to the permanent rotation.

Knowing when to trigger the animation

First, we need a way to know when we reach a section. There are plenty of ways of doing that and we could even use a library, but in our case, we can use the scrollY value and do some math to find the current section.

After creating the scrollY variable, create a currentSection variable and set it to 0:

let scrollY = window.scrollY
let currentSection = 0

In the 'scroll' event callback function, calculate the current section by dividing the scrollY by sizes.height:

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY

    const newSection = scrollY / sizes.height
    
    console.log(newSection)
})

This works because each section is exactly one height of the viewport.

To get the exact section instead of that float value, we can use Math.round():

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY

    const newSection = Math.round(scrollY / sizes.height)
    
    console.log(newSection)
})

We can now test if newSection is different from currentSection. If so, that means we changed the section and we can update the currentSection in order to do our animation:

window.addEventListener('scroll', () =>
{
    scrollY = window.scrollY
    const newSection = Math.round(scrollY / sizes.height)

    if(newSection != currentSection)
    {
        currentSection = newSection

        console.log('changed', currentSection)
    }
})

Animating the meshes

We can now animate the meshes and, to do that, we are going to use GSAP.

The GSAP library is already loaded from the HTML file as we did for Three.js.

Then, in the if statement we did earlier, we can do the animation with gsap.to():

window.addEventListener('scroll', () =>
{
    // ...
    
    if(newSection != currentSection)
    {
        // ...

        gsap.to(
            sectionMeshes[currentSection].rotation,
            {
                duration: 1.5,
                ease: 'power2.inOut',
                x: '+=6',
                y: '+=3'
            }
        )
    }
})

While this code is valid, it will unfortunately not work. The reason is that, on each frame, we are already updating the rotation.x and rotation.y of each mesh with the elapsedTime.

To fix that, in the tick function, instead of setting a very specific rotation based on the elapsedTime, we are going to add the deltaTime to the current rotation:

const tick = () =>
{
    // ...

    for(const mesh of sectionMeshes)
    {
        mesh.rotation.x += deltaTime * 0.1
        mesh.rotation.y += deltaTime * 0.12
    }

    // ...
}

Final code

You can download the final project here https://threejs-journey.com/resources/codrops/threejs-scroll-based-animation/final.zip

Go further

We kept things really simple on purpose, but you can for sure go much further!

  • Add more content to the HTML
  • Animate other properties like the material
  • Animate the HTML texts
  • Improve the particles
  • Add more tweaks to the Debug UI
  • Test other colors
  • Add mobile and touch support
  • Etc.

If you liked this tutorial or want to learn more about WebGL and Three.js, join the Three.js Journey course!

As a reminder, here’s a 20% discount CODROPS1 for you 😉