In this tutorial, you will learn how to create a circular text animation in a 3D space using Three.js with a nice distortion effect enhanced with shaders.
I am using the three-msdf-text-utils tool to help rendering text in 3D space here, but you can use any other tool and have the same result.
At the end of the tutorial, you will be able to position text in a 3D environment and control the distortion animation based on the speed of the scroll.
Let’s dive in!
Initial Setup
The first step is to set up our 3D environment. Nothing fancy here—it’s a basic Three.js implementation. I just prefer to keep things organized, so there’s a main.js
file where everything is set up for all the other classes that may be needed in the future. It includes a requestAnimationFrame
loop and all necessary eventListener
implementations.
// main.js
import NormalizeWheel from "normalize-wheel";
import AutoBind from "auto-bind";
import Canvas from "./components/canvas";
class App {
constructor() {
AutoBind(this);
this.init();
this.update();
this.onResize();
this.addEventListeners();
}
init() {
this.canvas = new Canvas();
}
update() {
this.canvas.update();
requestAnimationFrame(this.update.bind(this));
}
onResize() {
window.requestAnimationFrame(() => {
if (this.canvas && this.canvas.onResize) {
this.canvas.onResize();
}
});
}
onTouchDown(event) {
event.stopPropagation();
if (this.canvas && this.canvas.onTouchDown) {
this.canvas.onTouchDown(event);
}
}
onTouchMove(event) {
event.stopPropagation();
if (this.canvas && this.canvas.onTouchMove) {
this.canvas.onTouchMove(event);
}
}
onTouchUp(event) {
event.stopPropagation();
if (this.canvas && this.canvas.onTouchUp) {
this.canvas.onTouchUp(event);
}
}
onWheel(event) {
const normalizedWheel = NormalizeWheel(event);
if (this.canvas && this.canvas.onWheel) {
this.canvas.onWheel(normalizedWheel);
}
}
addEventListeners() {
window.addEventListener("resize", this.onResize, { passive: true });
window.addEventListener("mousedown", this.onTouchDown, {
passive: true,
});
window.addEventListener("mouseup", this.onTouchUp, { passive: true });
window.addEventListener("pointermove", this.onTouchMove, {
passive: true,
});
window.addEventListener("touchstart", this.onTouchDown, {
passive: true,
});
window.addEventListener("touchmove", this.onTouchMove, {
passive: true,
});
window.addEventListener("touchend", this.onTouchUp, { passive: true });
window.addEventListener("wheel", this.onWheel, { passive: true });
}
}
export default new App();
Notice that we are initializing every event listener and requestAnimationFrame
here, and passing it to the canvas.js
class that we need to set up.
// canvas.js
import * as THREE from "three";
import GUI from "lil-gui";
export default class Canvas {
constructor() {
this.element = document.getElementById("webgl");
this.time = 0;
this.y = {
start: 0,
distance: 0,
end: 0,
};
this.createClock();
this.createDebug();
this.createScene();
this.createCamera();
this.createRenderer();
this.onResize();
}
createDebug() {
this.gui = new GUI();
this.debug = {};
}
createClock() {
this.clock = new THREE.Clock();
}
createScene() {
this.scene = new THREE.Scene();
}
createCamera() {
this.camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.camera.position.z = 5;
}
createRenderer() {
this.renderer = new THREE.WebGLRenderer({
canvas: this.element,
alpha: true,
antialias: true,
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
onTouchDown(event) {
this.isDown = true;
this.y.start = event.touches ? event.touches[0].clientY : event.clientY;
}
onTouchMove(event) {
if (!this.isDown) return;
this.y.end = event.touches ? event.touches[0].clientY : event.clientY;
}
onTouchUp(event) {
this.isDown = false;
this.y.end = event.changedTouches
? event.changedTouches[0].clientY
: event.clientY;
}
onWheel(event) {}
onResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const fov = this.camera.fov * (Math.PI / 180);
const height = 2 * Math.tan(fov / 2) * this.camera.position.z;
const width = height * this.camera.aspect;
this.sizes = {
width,
height,
};
}
update() {
this.renderer.render(this.scene, this.camera);
}
}
Explaining the Canvas
class setup
We start by creating the scene in createScene()
and storing it in this.scene
so we can pass it to our future 3D elements.
We create the camera in the createCamera()
method and the renderer in createRenderer()
, passing the canvas element and setting some basic options. I usually have some DOM elements on top of the canvas, so I typically set it to transparent (alpha: true)
, but you’re free to apply any background color.
Then, we initialize the onResize
function, which is very important. Here, we perform three key actions:
- Ensuring that our
<canvas>
element is always resized correctly to match the viewport dimensions. - Updating the
camera
aspect ratio by dividing the viewport width by its height. - Storing our size values, which represent a transformation based on the camera’s field of view (FOV) to convert pixels into the 3D environment.
Finally, our update
method serves as our requestAnimationFrame
loop, where we continuously render our 3D scene. We also have all the necessary event methods ready to handle scrolling later on, including onWheel
, onTouchMove
, onTouchDown
, and onTouchUp
.
Creating our text gallery
Let’s create our gallery of text by creating a gallery.js
file. I could have done it directly in canva.js
as it is a small tutorial but I like to keep things separately for future project expansion.
// gallery.js
import * as THREE from "three";
import { data } from "../utils/data";
import Text from "./text";
export default class Gallery {
constructor({ renderer, scene, camera, sizes, gui }) {
this.renderer = renderer;
this.scene = scene;
this.camera = camera;
this.sizes = sizes;
this.gui = gui;
this.group = new THREE.Group();
this.createText();
this.show();
}
createText() {
this.texts = data.map((element, index) => {
return new Text({
element,
scene: this.group,
sizes: this.sizes,
length: data.length,
index,
});
});
}
show() {
this.scene.add(this.group);
}
onTouchDown() {}
onTouchMove() {}
onTouchUp() {}
onWheel() {}
onResize({ sizes }) {
this.sizes = sizes;
}
update() {}
}
The Gallery
class is fairly straightforward for now. We need to have our renderer, scene, and camera to position everything in the 3D space.
We create a group using new THREE.Group()
to manage our collection of text more easily. Each text element will be generated based on an array of 20 text entries.
// utils/data.js
export const data = [
{ id: 1, title: "Aurora" },
{ id: 2, title: "Bungalow" },
{ id: 3, title: "Chatoyant" },
{ id: 4, title: "Demure" },
{ id: 5, title: "Denouement" },
{ id: 6, title: "Felicity" },
{ id: 7, title: "Idyllic" },
{ id: 8, title: "Labyrinth" },
{ id: 9, title: "Lagoon" },
{ id: 10, title: "Lullaby" },
{ id: 11, title: "Aurora" },
{ id: 12, title: "Bungalow" },
{ id: 13, title: "Chatoyant" },
{ id: 14, title: "Demure" },
{ id: 15, title: "Denouement" },
{ id: 16, title: "Felicity" },
{ id: 17, title: "Idyllic" },
{ id: 18, title: "Labyrinth" },
{ id: 19, title: "Lagoon" },
{ id: 20, title: "Lullaby" },
];
We will create our Text
class, but before that, we need to set up our gallery within the Canvas
class. To do this, we add a createGallery
method and pass it the necessary information.
// gallery.js
createGallery() {
this.gallery = new Gallery({
renderer: this.renderer,
scene: this.scene,
camera: this.camera,
sizes: this.sizes,
gui: this.gui,
});
}
Don’t forget to call the same method from the Canvas
class to the Gallery
class to maintain consistent information across our app.
// gallery.js
onResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const fov = this.camera.fov * (Math.PI / 180);
const height = 2 * Math.tan(fov / 2) * this.camera.position.z;
const width = height * this.camera.aspect;
this.sizes = {
width,
height,
};
if (this.gallery)
this.gallery.onResize({
sizes: this.sizes,
});
}
update() {
if (this.gallery) this.gallery.update();
this.renderer.render(this.scene, this.camera);
}
Now, let’s create our array of texts that we want to use in our gallery. We will define a createText
method and use .map
to generate new instances of the Text
class (new Text()
), which will represent each text element in the gallery.
// gallery.js
createText() {
this.texts = data.map((element, index) => {
return new Text({
element,
scene: this.group,
sizes: this.sizes,
length: data.length,
index,
});
});
}
Introducing three-msdf-text-utils
To render our text in 3D space, we will use three-msdf-text-utils. For this, we need a bitmap font and a font atlas, which we can generate using the msdf-bmfont online tool. First, we need to upload a .ttf
file containing the font we want to use. Here, I’ve chosen Neuton-Regular
from Google Fonts to keep things simple, but you can use any font you prefer. Next, you need to define the character set for the font. Make sure to include every letter—both uppercase and lowercase—along with every number if you want them to be displayed. Since I’m a cool guy, you can just copy and paste this one (spaces are important):
a b c d e f g h i j k l m n o p q r s t u v w x y z A B C D E F G H I J K L M N O P Q R S T U V W X Y Z 0 1 2 3 4 5 6 7 8 9
Next, click the “Create MSDF” button, and you will receive a JSON file and a PNG file—both of which are needed to render our text.
We can then follow the documentation to render our text, but we will need to tweak a few things to align with our coding approach. Specifically, we will need to:
- Load the font.
- Create a geometry.
- Create our mesh.
- Add our mesh to the scene.
- Include shader code from the documentation to allow us to add custom effects later.
To load the font, we will create a function to load the PNG file, which will act as a texture
for our material.
// text.js
loadFontAtlas(path) {
const promise = new Promise((resolve, reject) => {
const loader = new THREE.TextureLoader();
loader.load(path, resolve);
});
return promise;
}
Next, we create a this.load
function, which will be responsible for loading our font, creating the geometry, and generating the mesh.
// text.js
import atlasURL from "../assets/Neuton-Regular.png";
import fnt from "../assets/Neuton-Regular-msdf.json";
load() {
Promise.all([this.loadFontAtlas(atlasURL)]).then(([atlas]) => {
const geometry = new MSDFTextGeometry({
text: this.element.title,
font: fnt,
});
const material = new THREE.ShaderMaterial({
side: THREE.DoubleSide,
opacity: 0.5,
transparent: true,
defines: {
IS_SMALL: false,
},
extensions: {
derivatives: true,
},
uniforms: {
// Common
...uniforms.common,
// Rendering
...uniforms.rendering,
// Strokes
...uniforms.strokes,
},
vertexShader: vertex,
fragmentShader: fragment,
});
material.uniforms.uMap.value = atlas;
this.mesh = new THREE.Mesh(geometry, material);
this.scene.add(this.mesh);
this.createBounds({
sizes: this.sizes,
});
});
}
In this function, we are essentially following the documentation by importing our font and PNG file. We create our geometry using the MSDFTextGeometry
instance provided by three-msdf-text-utils
. Here, we specify which text we want to display (this.element.title
from our array) and the font.
Next, we create our material based on the documentation, which includes some options and essential uniforms to properly render our text.
You’ll notice in the documentation that the vertexShader
and fragmentShader
code are included directly. However, that is not the case here. Since I prefer to keep things separate, as mentioned earlier, I created two .glsl
files and included the vertex
and fragment
shader code from the documentation. This will be useful later when we implement our distortion animation.
To be able to import .glsl
files, we need to update our vite
configuration. We do this by adding a vite.config.js
file and installing vite-plugin-glsl.
// vite.config.js
import glsl from "vite-plugin-glsl";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [glsl()],
root: "",
base: "./",
});
We then use the code from the doc to have our fragment
and vertex
shader:
// shaders/text-fragment.glsl
// Varyings
varying vec2 vUv;
// Uniforms: Common
uniform float uOpacity;
uniform float uThreshold;
uniform float uAlphaTest;
uniform vec3 uColor;
uniform sampler2D uMap;
// Uniforms: Strokes
uniform vec3 uStrokeColor;
uniform float uStrokeOutsetWidth;
uniform float uStrokeInsetWidth;
// Utils: Median
float median(float r, float g, float b) {
return max(min(r, g), min(max(r, g), b));
}
void main() {
// Common
// Texture sample
vec3 s = texture2D(uMap, vUv).rgb;
// Signed distance
float sigDist = median(s.r, s.g, s.b) - 0.5;
float afwidth = 1.4142135623730951 / 2.0;
#ifdef IS_SMALL
float alpha = smoothstep(uThreshold - afwidth, uThreshold + afwidth, sigDist);
#else
float alpha = clamp(sigDist / fwidth(sigDist) + 0.5, 0.0, 1.0);
#endif
// Strokes
// Outset
float sigDistOutset = sigDist + uStrokeOutsetWidth * 0.5;
// Inset
float sigDistInset = sigDist - uStrokeInsetWidth * 0.5;
#ifdef IS_SMALL
float outset = smoothstep(uThreshold - afwidth, uThreshold + afwidth, sigDistOutset);
float inset = 1.0 - smoothstep(uThreshold - afwidth, uThreshold + afwidth, sigDistInset);
#else
float outset = clamp(sigDistOutset / fwidth(sigDistOutset) + 0.5, 0.0, 1.0);
float inset = 1.0 - clamp(sigDistInset / fwidth(sigDistInset) + 0.5, 0.0, 1.0);
#endif
// Border
float border = outset * inset;
// Alpha Test
if (alpha < uAlphaTest) discard;
// Output: Common
vec4 filledFragColor = vec4(uColor, uOpacity * alpha);
// Output: Strokes
vec4 strokedFragColor = vec4(uStrokeColor, uOpacity * border);
gl_FragColor = filledFragColor;
}
// shaders/text-vertex.glsl
// Attribute
attribute vec2 layoutUv;
attribute float lineIndex;
attribute float lineLettersTotal;
attribute float lineLetterIndex;
attribute float lineWordsTotal;
attribute float lineWordIndex;
attribute float wordIndex;
attribute float letterIndex;
// Varyings
varying vec2 vUv;
varying vec2 vLayoutUv;
varying vec3 vViewPosition;
varying vec3 vNormal;
varying float vLineIndex;
varying float vLineLettersTotal;
varying float vLineLetterIndex;
varying float vLineWordsTotal;
varying float vLineWordIndex;
varying float vWordIndex;
varying float vLetterIndex;
void main() {
// Varyings
vUv = uv;
vLayoutUv = layoutUv;
vec4 mvPosition = vec4(position, 1.0);
vViewPosition = -mvPosition.xyz;
vNormal = normal;
vLineIndex = lineIndex;
vLineLettersTotal = lineLettersTotal;
vLineLetterIndex = lineLetterIndex;
vLineWordsTotal = lineWordsTotal;
vLineWordIndex = lineWordIndex;
vWordIndex = wordIndex;
vLetterIndex = letterIndex;
// Output
mvPosition = modelViewMatrix * mvPosition;
gl_Position = projectionMatrix * mvPosition;
}
Now, we need to define the scale of our mesh and open our browser to finally see something on the screen. We will start with a scale of 0.008
and apply it to our mesh. So far, the Text.js
file looks like this:
// text.js
import * as THREE from "three";
import { MSDFTextGeometry, uniforms } from "three-msdf-text-utils";
import atlasURL from "../assets/Neuton-Regular.png";
import fnt from "../assets/Neuton-Regular-msdf.json";
import vertex from "../shaders/text-vertex.glsl";
import fragment from "../shaders/text-fragment.glsl";
export default class Text {
constructor({ element, scene, sizes, index, length }) {
this.element = element;
this.scene = scene;
this.sizes = sizes;
this.index = index;
this.scale = 0.008;
this.load();
}
load() {
Promise.all([this.loadFontAtlas(atlasURL)]).then(([atlas]) => {
const geometry = new MSDFTextGeometry({
text: this.element.title,
font: fnt,
});
const material = new THREE.ShaderMaterial({
side: THREE.DoubleSide,
opacity: 0.5,
transparent: true,
defines: {
IS_SMALL: false,
},
extensions: {
derivatives: true,
},
uniforms: {
// Common
...uniforms.common,
// Rendering
...uniforms.rendering,
// Strokes
...uniforms.strokes,
},
vertexShader: vertex,
fragmentShader: fragment,
});
material.uniforms.uMap.value = atlas;
this.mesh = new THREE.Mesh(geometry, material);
this.scene.add(this.mesh);
this.createBounds({
sizes: this.sizes,
});
});
}
loadFontAtlas(path) {
const promise = new Promise((resolve, reject) => {
const loader = new THREE.TextureLoader();
loader.load(path, resolve);
});
return promise;
}
createBounds({ sizes }) {
if (this.mesh) {
this.updateScale();
}
}
updateScale() {
this.mesh.scale.set(this.scale, this.scale, this.scale);
}
onResize(sizes) {
this.sizes = sizes;
this.createBounds({
sizes: this.sizes,
});
}
}
Scaling and positioning our text
Let’s open our browser and launch the project to see the result:
We can see some text, but it’s white and stacked on top of each other. Let’s fix that.
First, let’s change the text color to an almost black shade. three-msdf
provides a uColor
uniform, but let’s practice our GLSL
skills and add our own uniform manually.
We can introduce a new uniform called uColorBack
, which will be a Vector3
representing a black color #222222
. However, in Three.js, this is handled differently:
// text.js
uniforms: {
// custom
uColorBlack: { value: new THREE.Vector3(0.133, 0.133, 0.133) },
// Common
...uniforms.common,
// Rendering
...uniforms.rendering,
// Strokes
...uniforms.strokes,
},
But this is not enough—we also need to pass the uniform to our fragment
shader and use it instead of the default uColor
:
// shaders/text-fragment.glsl
uniform vec3 uColorBlack;
// Output: Common
vec4 filledFragColor = vec4(uColorBlack, uOpacity * alpha);
And now we have this:
It is now black, but we’re still far from the final result—don’t worry, it will look better soon! First, let’s create some space between the text elements so we can see them properly. We’ll add a this.updateY
method to position each text element correctly based on its index
.
// text.js
createBounds({ sizes }) {
if (this.mesh) {
this.updateScale();
this.updateY();
}
}
updateY() {
this.mesh.position.y = this.index * 0.5;
}
We move the mesh
along the y-axis based on its index
and multiply it by 0.5
for now to create some spacing between the text elements. Now, we have this:
It’s better, but we still can’t read the text properly.
It appears to be slightly rotated along the y-axis, so we just need to invert the y-scaling by doing this:
// text.js
updateScale() {
this.mesh.scale.set(this.scale, -this.scale, this.scale);
}
…and now we can finally see our text properly! Things are moving in the right direction.
Custom scroll
Let’s implement our scroll behavior so we can view each rendered text element. I could have used various libraries like Lenis
or Virtual Scroll
, but I prefer having full control over the functionality. So, we’ll implement a custom scroll system within our 3D space.
Back in our Canvas
class, we have already set up event listeners for wheel
and touch
events and implemented our scroll logic. Now, we need to pass this information to our Gallery
class.
// canvas.js
onTouchDown(event) {
this.isDown = true;
this.y.start = event.touches ? event.touches[0].clientY : event.clientY;
if (this.gallery) this.gallery.onTouchDown({ y: this.y.start });
}
onTouchMove(event) {
if (!this.isDown) return;
this.y.end = event.touches ? event.touches[0].clientY : event.clientY;
if (this.gallery) this.gallery.onTouchMove({ y: this.y });
}
onTouchUp(event) {
this.isDown = false;
this.y.end = event.changedTouches
? event.changedTouches[0].clientY
: event.clientY;
if (this.gallery) this.gallery.onTouchUp({ y: this.y });
}
onWheel(event) {
if (this.gallery) this.gallery.onWheel(event);
}
We keep track of our scroll and pass this.y
, which contains the start, end, and distance of our scroll along the y-axis. For the wheel
event, we normalize the event values to ensure consistency across all browsers and then pass them directly to our Gallery
class.
Now, in our Gallery
class, we can prepare our scroll logic by defining some necessary variables.
// gallery.js
this.y = {
current: 0,
target: 0,
lerp: 0.1,
};
this.scrollCurrent = {
y: 0,
// x: 0
};
this.scroll = {
y: 0,
// x: 0
};
this.y
contains the current
, target
, and lerp
properties, allowing us to smooth out the scroll using linear interpolation.
Since we are passing data from both the touch
and wheel
events in the Canvas
class, we need to include the same methods in our Gallery
class and handle the necessary calculations for both scrolling and touch movement.
// gallery.js
onTouchDown({ y }) {
this.scrollCurrent.y = this.scroll.y;
}
onTouchMove({ y }) {
const yDistance = y.start - y.end;
this.y.target = this.scrollCurrent.y - yDistance;
}
onTouchUp({ y }) {}
onWheel({ pixelY }) {
this.y.target -= pixelY;
}
Now, let’s smooth the scrolling effect to create a more natural feel by using the lerp
function in our update
method:
// gallery.js
update() {
this.y.current = lerp(this.y.current, this.y.target, this.y.lerp);
this.scroll.y = this.y.current;
}
Now that we have a properly smooth scroll, we need to pass the scroll value to each text element to update their position accordingly, like this:
// gallery.js
update() {
this.y.current = lerp(this.y.current, this.y.target, this.y.lerp);
this.scroll.y = this.y.current;
this.texts.map((text) =>
text.update(this.scroll)
);
}
Now, we also need to add an update
method in the Text
class to retrieve the scroll position and apply it to the mesh position.
// text.js
updateY(y = 0) {
this.mesh.position.y = this.index * 0.5 - y;
}
update(scroll) {
if (this.mesh) {
this.updateY(scroll.y * 0.005);
}
}
We receive the scroll position along the y-axis based on the amount scrolled using the wheel
event and pass it to the updateY
method. For now, we multiply it by a hardcoded value to prevent the values from being too large. Then, we subtract it from our mesh position, and we finally achieve this result:
Circle it
Now the fun part begins! Since we want a circular layout, it’s time to use some trigonometry to position each text element around a circle. There are probably multiple approaches to achieve this, and some might be simpler, but I’ve come up with a nice method based on mathematical calculations. Let’s start by rotating the text elements along the Z-axis to form a full circle. First, we need to define some variables:
// text.js
this.numberOfText = this.length;
this.angleCalc = ((this.numberOfText / 10) * Math.PI) / this.numberOfText;
Let’s break it down to understand the calculation:
We want to position each text element evenly around a circle. A full circle has an angle of 2π radians (equivalent to 360 degrees).
Since we have this.numberOfText
text elements to arrange, we need to determine the angle each text should occupy on the circle.
So we have:
- The full circle angle: 360° (or 2π radians).
- The space each text occupies: To evenly distribute the texts, we divide the circle into equal parts based on the total number of texts.
So, the angle each text will occupy is the total angle of the circle (2π radians, written as 2 * Math.PI
) divided by the number of texts. This gives us the basic angle:
this.angleCalc = (2 * Math.PI) / this.numberOfText;
But we’re doing something slightly different here:
this.angleCalc = ((this.numberOfText / 10) * Math.PI) / this.numberOfText;
What we’re doing here is adjusting the total number of texts by dividing it by 10, which in this case is the same as our basic calculation since we have 20 texts, and 20/10 = 2. However, this number of texts could be changed dynamically.
By scaling our angle this way, we can control the tightness of the layout based on that factor. The purpose of dividing by 10 is to make the circle more spread out or tighter, depending on our design needs. This provides a way to fine-tune the spacing between each text.
Finally, here’s the key takeaway: We calculate how much angular space each text occupies and tweak it with a factor (/ 10
) to adjust the spacing, giving us control over the layout’s appearance. This calculation will later be useful for positioning our mesh along the X and Y axes.
Now, let’s apply a similar calculation for the Z-axis by doing this:
// text.js
updateZ() {
this.mesh.rotation.z = (this.index / this.numberOfText) * 2 * Math.PI;
}
We rotate each text based on its index, dividing it by the total number of texts. Then, we multiply the result by our rotation angle, which, as explained earlier, is the total angle of the circle (2 * Math.PI
). This gives us the following result:
We’re almost there! We can see the beginning of a circular rotation, but we still need to position the elements along the X and Y axes to form a full circle. Let’s start with the X-axis.
Now, we can use our this.angleCalc
and apply it to each mesh based on its index. Using the trigonometric function cosine
, we can position each text element around the circle along the horizontal axis, like this:
// text.js
updateX() {
this.angleX = this.index * this.angleCalc;
this.mesh.position.x = Math.cos(this.angleX);
}
And now we have this result:
It’s happening! We’re close to the final result. Now, we need to apply the same logic to the Y-axis. This time, we’ll use the trigonometric function sine
to position each text element along the vertical axis.
// text.js
updateY(y = 0) {
// this.mesh.position.y = this.index * 0.5 - y;
this.angleY = this.index * this.angleCalc;
this.mesh.position.y = Math.sin(this.angleY);
}
And now we have our final result:
For now, the text elements are correctly positioned, but we can’t make the circle spin indefinitely because we need to apply the scroll amount to the X, Y, and Z positions—just as we initially did for the Y position alone. Let’s pass the scroll.y
value to the updatePosition
method for each text element and see the result.
// text.js
updateZ(z = 0) {
this.mesh.rotation.z = (this.index / this.numberOfText) * 2 * Math.PI - z;
}
updateX(x = 0) {
this.angleX = this.index * this.angleCalc - x;
this.mesh.position.x = Math.cos(this.angleX);
}
updateY(y = 0) {
this.angleY = this.index * this.angleCalc - y;
this.mesh.position.y = Math.sin(this.angleY);
}
update(scroll) {
if (this.mesh) {
this.updateY(scroll.y * 0.005);
this.updateX(scroll.y * 0.005);
this.updateZ(scroll.y * 0.005);
}
}
Currently, we are multiplying our scroll position by a hardcoded value that controls the spiral speed when scrolling. In the final code, this value has been added to our GUI
in the top right corner, allowing you to tweak it and find the perfect setting for your needs.
At this point, we have achieved a very nice effect:
Animate it!
To make the circular layout more interesting, we can make the text react to the scroll speed, creating a dynamic effect that resembles a flower, paper folding, or any organic motion using shader
code.
First, we need to calculate the scroll speed based on the amount of scrolling and pass this value to our Text
class. Let’s define some variables in the same way we did for the scroll:
// gallery.js
this.speed = {
current: 0,
target: 0,
lerp: 0.1,
};
We calculate the distance traveled and use linear interpolation again to smooth the value. Finally, we pass it to our Text
class.
// gallery.js
update() {
this.y.current = lerp(this.y.current, this.y.target, this.y.lerp);
this.scroll.y = this.y.current;
this.speed.target = (this.y.target - this.y.current) * 0.001;
this.speed.current = lerp(
this.speed.current,
this.speed.target,
this.speed.lerp
);
this.texts.map((text) =>
text.update(
this.scroll,
this.circleSpeed,
this.speed.current,
this.amplitude
)
);
}
Since we want our animation to be driven by the speed value, we need to pass it to our vertex
shader. To do this, we create a new uniform in our Text
class named uSpeed
.
// gallery.js
uniforms: {
// custom
uColorBlack: { value: new THREE.Vector3(0.133, 0.133, 0.133) },
// speed
uSpeed: { value: 0.0 },
uAmplitude: { value: this.amplitude },
// Common
...uniforms.common,
// Rendering
...uniforms.rendering,
// Strokes
...uniforms.strokes,
},
Update it in our update function like so:
// gallery.js
update(scroll, speed) {
if (this.mesh) {
this.mesh.material.uniforms.uSpeed.value = speed;
this.updateY(scroll.y * this.circleSpeed);
this.updateX(scroll.y * this.circleSpeed);
this.updateZ(scroll.y * this.circleSpeed);
}
}
Now that we have access to our speed and have created a new uniform, it’s time to pass it to our vertex
shader and create the animation.
To achieve a smooth and visually appealing rotation, we can use a very useful function from this Gist (specifically, the 3D version). This function helps refine our transformations, making our vertex
shader look like this:
// shaders/text-vertex.glsl
// Attribute
attribute vec2 layoutUv;
attribute float lineIndex;
attribute float lineLettersTotal;
attribute float lineLetterIndex;
attribute float lineWordsTotal;
attribute float lineWordIndex;
attribute float wordIndex;
attribute float letterIndex;
// Varyings
varying vec2 vUv;
varying vec2 vLayoutUv;
varying vec3 vViewPosition;
varying vec3 vNormal;
varying float vLineIndex;
varying float vLineLettersTotal;
varying float vLineLetterIndex;
varying float vLineWordsTotal;
varying float vLineWordIndex;
varying float vWordIndex;
varying float vLetterIndex;
// ROTATE FUNCTION STARTS HERE
mat4 rotationMatrix(vec3 axis, float angle) {
axis = normalize(axis);
float s = sin(angle);
float c = cos(angle);
float oc = 1.0 - c;
return mat4(oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s, 0.0,
oc * axis.x * axis.y + axis.z * s, oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s, 0.0,
oc * axis.z * axis.x - axis.y * s, oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c, 0.0,
0.0, 0.0, 0.0, 1.0);
}
vec3 rotate(vec3 v, vec3 axis, float angle) {
mat4 m = rotationMatrix(axis, angle);
return (m * vec4(v, 1.0)).xyz;
}
// ROTATE FUNCTION ENDS HERE
void main() {
// Varyings
vUv = uv;
vLayoutUv = layoutUv;
vNormal = normal;
vLineIndex = lineIndex;
vLineLettersTotal = lineLettersTotal;
vLineLetterIndex = lineLetterIndex;
vLineWordsTotal = lineWordsTotal;
vLineWordIndex = lineWordIndex;
vWordIndex = wordIndex;
vLetterIndex = letterIndex;
vec4 mvPosition = vec4(position, 1.0);
// Output
mvPosition = modelViewMatrix * mvPosition;
gl_Position = projectionMatrix * mvPosition;
vViewPosition = -mvPosition.xyz;
}
Let’s do this step by step. First we pass our uSpeed
uniform by declaring it:
uniform float uSpeed;
Then we need to create a new vec3 variable called newPosition
which is equal to our final position
in order to tweak it:
vec3 newPosition = position;
We update the final vec4 mvPosition
to use this newPosition
variable:
vec4 mvPosition = vec4(newPosition, 1.0);
So far, nothing has changed visually, but now we can apply effects and distortions to our newPosition
, which will be reflected in our text. Let’s use the rotate
function imported from the Gist and see the result:
newPosition = rotate(newPosition, vec3(0.0, 0.0, 1.0), uSpeed * position.x);
We are essentially using the function to define the distortion angle based on the x-position of the text. We then multiply this value by the scroll speed, which we previously declared as a uniform. This gives us the following result:
As you can see, the effect is too intense, so we need to multiply it by a smaller number and fine-tune it to find the perfect balance.
Let’s practice our shader coding skills by adding this parameter to the GUI
as a uniform. We’ll create a new uniform called uAmplitude
and use it to control the intensity of the effect:
uniform float uSpeed;
uniform float uAmplitude;
newPosition = rotate(newPosition, vec3(0.0, 0.0, 1.0), uSpeed * position.x * uAmplitude);
We can create a variable this.amplitude = 0.004
in our Gallery
class, add it to the GUI
for real-time control, and pass it to our Text
class as we did before:
// gallery.js
this.amplitude = 0.004;
this.gui.add(this, "amplitude").min(0).max(0.01).step(0.001);
update() {
this.y.current = lerp(this.y.current, this.y.target, this.y.lerp);
this.scroll.y = this.y.current;
this.speed.target = (this.y.target - this.y.current) * 0.001;
this.speed.current = lerp(
this.speed.current,
this.speed.target,
this.speed.lerp
);
this.texts.map((text) =>
text.update(
this.scroll,
this.speed.current,
this.amplitude
)
);
}
…and in our text class:
// text.js
update(scroll, circleSpeed, speed, amplitude) {
this.circleSpeed = circleSpeed;
if (this.mesh) {
this.mesh.material.uniforms.uSpeed.value = speed;
// our amplitude here
this.mesh.material.uniforms.uAmplitude.value = amplitude;
this.updateY(scroll.y * this.circleSpeed);
this.updateX(scroll.y * this.circleSpeed);
this.updateZ(scroll.y * this.circleSpeed);
}
}
And now, you have the final result with full control over the effect via the GUI, located in the top right corner:
BONUS: Group positioning and enter animation
Instead of keeping the circle at the center, we can move it to the left side of the screen to display only half of it. This approach leaves space on the screen, allowing us to synchronize the text with images, for example (but that’s for another tutorial).
Remember that when initializing our 3D scene, we calculated the sizes of our 3D space and stored them in this.sizes
. Since all text elements are grouped inside a Three.js group, we can move the entire spiral accordingly.
By dividing the group’s position on the X-axis by 2, we shift it from the center toward the side. We can then adjust its placement: use a negative value to move it to the left and a positive value to move it to the right.
this.group.position.x = -this.sizes.width / 2;
We now have our spiral to the left side of the screen.
To make the page entry more dynamic, we can create an animation where the group moves from outside the screen to its final position while spinning slightly using GSAP
. Nothing too complex here—you can customize it however you like and use any animation library you prefer. I’ve chosen to use GSAP
and trigger the animation right after adding the group to the scene, like this:
// gallery.js
show() {
this.scene.add(this.group);
this.timeline = gsap.timeline();
this.timeline
.fromTo(
this.group.position,
{
x: -this.sizes.width * 2, // outside of the screen
},
{
duration: 0.8,
ease: easing,
x: -this.sizes.width / 2, // final position
}
)
.fromTo(
this.y,
{
// small calculation to be minimum - 1500 to have at least a small movement and randomize it to have a different effect on every landing
target: Math.min(-1500, -Math.random() * window.innerHeight * 6),
},
{
target: 0,
duration: 0.8,
ease: easing,
},
"<" // at the same time of the first animation
);
}
That’s a wrap! We’ve successfully implemented the effect.
The GUI is included in the repository, allowing you to experiment with amplitude and spiral speed. I’d love to see your creations and how you build upon this demo. Feel free to ask me any questions or share your experiments with me on Twitter or LinkedIn (I’m more active on LinkedIn).