From our sponsor: Meco is a distraction-free space for reading and discovering newsletters, separate from the inbox.
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
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 😉