On this tutorial, you’ll discover ways to create a round textual content animation in a 3D area utilizing Three.js with a pleasant distortion impact enhanced with shaders.
I’m utilizing the three-msdf-text-utils instrument to assist rendering textual content in 3D area right here, however you should use every other instrument and have the identical end result.
On the finish of the tutorial, it is possible for you to to place textual content in a 3D surroundings and management the distortion animation based mostly on the velocity of the scroll.
Let’s dive in!
Preliminary Setup
Step one is to arrange our 3D surroundings. Nothing fancy right here—it’s a fundamental Three.js implementation. I simply desire to maintain issues organized, so there’s a essential.js
file the place every little thing is about up for all the opposite lessons that could be wanted sooner or later. It features a requestAnimationFrame
loop and all essential eventListener
implementations.
// essential.js
import NormalizeWheel from "normalize-wheel";
import AutoBind from "auto-bind";
import Canvas from "./elements/canvas";
class App {
constructor() {
AutoBind(this);
this.init();
this.replace();
this.onResize();
this.addEventListeners();
}
init() {
this.canvas = new Canvas();
}
replace() {
this.canvas.replace();
requestAnimationFrame(this.replace.bind(this));
}
onResize() {
window.requestAnimationFrame(() => {
if (this.canvas && this.canvas.onResize) {
this.canvas.onResize();
}
});
}
onTouchDown(occasion) {
occasion.stopPropagation();
if (this.canvas && this.canvas.onTouchDown) {
this.canvas.onTouchDown(occasion);
}
}
onTouchMove(occasion) {
occasion.stopPropagation();
if (this.canvas && this.canvas.onTouchMove) {
this.canvas.onTouchMove(occasion);
}
}
onTouchUp(occasion) {
occasion.stopPropagation();
if (this.canvas && this.canvas.onTouchUp) {
this.canvas.onTouchUp(occasion);
}
}
onWheel(occasion) {
const normalizedWheel = NormalizeWheel(occasion);
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();
Discover that we’re initializing each occasion listener and requestAnimationFrame
right here, and passing it to the canvas.js
class that we have to arrange.
// canvas.js
import * as THREE from "three";
import GUI from "lil-gui";
export default class Canvas {
constructor() {
this.factor = doc.getElementById("webgl");
this.time = 0;
this.y = {
begin: 0,
distance: 0,
finish: 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.digital camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.digital camera.place.z = 5;
}
createRenderer() {
this.renderer = new THREE.WebGLRenderer({
canvas: this.factor,
alpha: true,
antialias: true,
});
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
onTouchDown(occasion) {
this.isDown = true;
this.y.begin = occasion.touches ? occasion.touches[0].clientY : occasion.clientY;
}
onTouchMove(occasion) {
if (!this.isDown) return;
this.y.finish = occasion.touches ? occasion.touches[0].clientY : occasion.clientY;
}
onTouchUp(occasion) {
this.isDown = false;
this.y.finish = occasion.changedTouches
? occasion.changedTouches[0].clientY
: occasion.clientY;
}
onWheel(occasion) {}
onResize() {
this.digital camera.facet = window.innerWidth / window.innerHeight;
this.digital camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const fov = this.digital camera.fov * (Math.PI / 180);
const peak = 2 * Math.tan(fov / 2) * this.digital camera.place.z;
const width = peak * this.digital camera.facet;
this.sizes = {
width,
peak,
};
}
replace() {
this.renderer.render(this.scene, this.digital camera);
}
}
Explaining the Canvas
class setup
We begin by creating the scene in createScene()
and storing it in this.scene
so we will move it to our future 3D components.
We create the digital camera within the createCamera()
methodology and the renderer in createRenderer()
, passing the canvas factor and setting some fundamental choices. I normally have some DOM components on high of the canvas, so I usually set it to clear (alpha: true)
, however you’re free to use any background shade.
Then, we initialize the onResize
operate, which is essential. Right here, we carry out three key actions:
- Making certain that our
<canvas>
factor is at all times resized appropriately to match the viewport dimensions. - Updating the
digital camera
facet ratio by dividing the viewport width by its peak. - Storing our dimension values, which symbolize a metamorphosis based mostly on the digital camera’s discipline of view (FOV) to transform pixels into the 3D surroundings.
Lastly, our replace
methodology serves as our requestAnimationFrame
loop, the place we repeatedly render our 3D scene. We even have all the mandatory occasion strategies able to deal with scrolling afterward, together with onWheel
, onTouchMove
, onTouchDown
, and onTouchUp
.
Creating our textual content gallery
Let’s create our gallery of textual content by making a gallery.js
file. I may have carried out it immediately in canva.js
as it’s a small tutorial however I wish to hold issues individually for future undertaking enlargement.
// gallery.js
import * as THREE from "three";
import { information } from "../utils/information";
import Textual content from "./textual content";
export default class Gallery {
constructor({ renderer, scene, digital camera, sizes, gui }) {
this.renderer = renderer;
this.scene = scene;
this.digital camera = digital camera;
this.sizes = sizes;
this.gui = gui;
this.group = new THREE.Group();
this.createText();
this.present();
}
createText() {
this.texts = information.map((factor, index) => {
return new Textual content({
factor,
scene: this.group,
sizes: this.sizes,
size: information.size,
index,
});
});
}
present() {
this.scene.add(this.group);
}
onTouchDown() {}
onTouchMove() {}
onTouchUp() {}
onWheel() {}
onResize({ sizes }) {
this.sizes = sizes;
}
replace() {}
}
The Gallery
class is pretty easy for now. We have to have our renderer, scene, and digital camera to place every little thing within the 3D area.
We create a bunch utilizing new THREE.Group()
to handle our assortment of textual content extra simply. Every textual content factor will probably be generated based mostly on an array of 20 textual content entries.
// utils/information.js
export const information = [
{ 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’ll create our Textual content
class, however earlier than that, we have to arrange our gallery inside the Canvas
class. To do that, we add a createGallery
methodology and move it the mandatory data.
// gallery.js
createGallery() {
this.gallery = new Gallery({
renderer: this.renderer,
scene: this.scene,
digital camera: this.digital camera,
sizes: this.sizes,
gui: this.gui,
});
}
Don’t overlook to name the identical methodology from the Canvas
class to the Gallery
class to keep up constant data throughout our app.
// gallery.js
onResize() {
this.digital camera.facet = window.innerWidth / window.innerHeight;
this.digital camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const fov = this.digital camera.fov * (Math.PI / 180);
const peak = 2 * Math.tan(fov / 2) * this.digital camera.place.z;
const width = peak * this.digital camera.facet;
this.sizes = {
width,
peak,
};
if (this.gallery)
this.gallery.onResize({
sizes: this.sizes,
});
}
replace() {
if (this.gallery) this.gallery.replace();
this.renderer.render(this.scene, this.digital camera);
}
Now, let’s create our array of texts that we wish to use in our gallery. We’ll outline a createText
methodology and use .map
to generate new situations of the Textual content
class (new Textual content()
), which is able to symbolize every textual content factor within the gallery.
// gallery.js
createText() {
this.texts = information.map((factor, index) => {
return new Textual content({
factor,
scene: this.group,
sizes: this.sizes,
size: information.size,
index,
});
});
}
Introducing three-msdf-text-utils
To render our textual content in 3D area, we’ll use three-msdf-text-utils. For this, we want a bitmap font and a font atlas, which we will generate utilizing the msdf-bmfont on-line instrument. First, we have to add a .ttf
file containing the font we wish to use. Right here, I’ve chosen Neuton-Common
from Google Fonts to maintain issues easy, however you should use any font you favor. Subsequent, it’s essential to outline the character set for the font. Be sure to incorporate each letter—each uppercase and lowercase—together with each quantity if you’d like them to be displayed. Since I’m a cool man, you possibly can simply copy and paste this one (areas are vital):
a b c d e f g h i j okay 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 Okay 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
Subsequent, click on the “Create MSDF” button, and you’ll obtain a JSON file and a PNG file—each of that are wanted to render our textual content.
We are able to then comply with the documentation to render our textual content, however we might want to tweak a couple of issues to align with our coding method. Particularly, we might want to:
- Load the font.
- Create a geometry.
- Create our mesh.
- Add our mesh to the scene.
- Embody shader code from the documentation to permit us so as to add customized results later.
To load the font, we’ll create a operate to load the PNG file, which is able to act as a texture
for our materials.
// textual content.js
loadFontAtlas(path) {
const promise = new Promise((resolve, reject) => {
const loader = new THREE.TextureLoader();
loader.load(path, resolve);
});
return promise;
}
Subsequent, we create a this.load
operate, which will probably be chargeable for loading our font, creating the geometry, and producing the mesh.
// textual content.js
import atlasURL from "../belongings/Neuton-Common.png";
import fnt from "../belongings/Neuton-Common-msdf.json";
load() {
Promise.all([this.loadFontAtlas(atlasURL)]).then(([atlas]) => {
const geometry = new MSDFTextGeometry({
textual content: this.factor.title,
font: fnt,
});
const materials = new THREE.ShaderMaterial({
aspect: THREE.DoubleSide,
opacity: 0.5,
clear: true,
defines: {
IS_SMALL: false,
},
extensions: {
derivatives: true,
},
uniforms: {
// Widespread
...uniforms.widespread,
// Rendering
...uniforms.rendering,
// Strokes
...uniforms.strokes,
},
vertexShader: vertex,
fragmentShader: fragment,
});
materials.uniforms.uMap.worth = atlas;
this.mesh = new THREE.Mesh(geometry, materials);
this.scene.add(this.mesh);
this.createBounds({
sizes: this.sizes,
});
});
}
On this operate, we’re basically following the documentation by importing our font and PNG file. We create our geometry utilizing the MSDFTextGeometry
occasion offered by three-msdf-text-utils
. Right here, we specify which textual content we wish to show (this.factor.title
from our array) and the font.
Subsequent, we create our materials based mostly on the documentation, which incorporates some choices and important uniforms to correctly render our textual content.
You’ll discover within the documentation that the vertexShader
and fragmentShader
code are included immediately. Nonetheless, that’s not the case right here. Since I desire to maintain issues separate, as talked about earlier, I created two .glsl
recordsdata and included the vertex
and fragment
shader code from the documentation. This will probably be helpful later once we implement our distortion animation.
To have the ability to import .glsl
recordsdata, we have to replace our vite
configuration. We do that by including a vite.config.js
file and putting in 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
various vec2 vUv;
// Uniforms: Widespread
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 essential() {
// Widespread
// Texture pattern
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 Take a look at
if (alpha < uAlphaTest) discard;
// Output: Widespread
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
various vec2 vUv;
various vec2 vLayoutUv;
various vec3 vViewPosition;
various vec3 vNormal;
various float vLineIndex;
various float vLineLettersTotal;
various float vLineLetterIndex;
various float vLineWordsTotal;
various float vLineWordIndex;
various float vWordIndex;
various float vLetterIndex;
void essential() {
// Varyings
vUv = uv;
vLayoutUv = layoutUv;
vec4 mvPosition = vec4(place, 1.0);
vViewPosition = -mvPosition.xyz;
vNormal = regular;
vLineIndex = lineIndex;
vLineLettersTotal = lineLettersTotal;
vLineLetterIndex = lineLetterIndex;
vLineWordsTotal = lineWordsTotal;
vLineWordIndex = lineWordIndex;
vWordIndex = wordIndex;
vLetterIndex = letterIndex;
// Output
mvPosition = modelViewMatrix * mvPosition;
gl_Position = projectionMatrix * mvPosition;
}
Now, we have to outline the dimensions of our mesh and open our browser to lastly see one thing on the display. We’ll begin with a scale of 0.008
and apply it to our mesh. Up to now, the Textual content.js
file appears to be like like this:
// textual content.js
import * as THREE from "three";
import { MSDFTextGeometry, uniforms } from "three-msdf-text-utils";
import atlasURL from "../belongings/Neuton-Common.png";
import fnt from "../belongings/Neuton-Common-msdf.json";
import vertex from "../shaders/text-vertex.glsl";
import fragment from "../shaders/text-fragment.glsl";
export default class Textual content {
constructor({ factor, scene, sizes, index, size }) {
this.factor = factor;
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({
textual content: this.factor.title,
font: fnt,
});
const materials = new THREE.ShaderMaterial({
aspect: THREE.DoubleSide,
opacity: 0.5,
clear: true,
defines: {
IS_SMALL: false,
},
extensions: {
derivatives: true,
},
uniforms: {
// Widespread
...uniforms.widespread,
// Rendering
...uniforms.rendering,
// Strokes
...uniforms.strokes,
},
vertexShader: vertex,
fragmentShader: fragment,
});
materials.uniforms.uMap.worth = atlas;
this.mesh = new THREE.Mesh(geometry, materials);
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 textual content
Let’s open our browser and launch the undertaking to see the end result:

We are able to see some textual content, however it’s white and stacked on high of one another. Let’s repair that.
First, let’s change the textual content shade to an nearly black shade. three-msdf
supplies a uColor
uniform, however let’s observe our GLSL
expertise and add our personal uniform manually.
We are able to introduce a brand new uniform referred to as uColorBack
, which will probably be a Vector3
representing a black shade #222222
. Nonetheless, in Three.js, that is dealt with in another way:
// textual content.js
uniforms: {
// customized
uColorBlack: { worth: new THREE.Vector3(0.133, 0.133, 0.133) },
// Widespread
...uniforms.widespread,
// Rendering
...uniforms.rendering,
// Strokes
...uniforms.strokes,
},
However this isn’t sufficient—we additionally have to move the uniform to our fragment
shader and use it as a substitute of the default uColor
:
// shaders/text-fragment.glsl
uniform vec3 uColorBlack;
// Output: Widespread
vec4 filledFragColor = vec4(uColorBlack, uOpacity * alpha);
And now we’ve got this:

It’s now black, however we’re nonetheless removed from the ultimate end result—don’t fear, it would look higher quickly! First, let’s create some area between the textual content components so we will see them correctly. We’ll add a this.updateY
methodology to place every textual content factor appropriately based mostly on its index
.
// textual content.js
createBounds({ sizes }) {
if (this.mesh) {
this.updateScale();
this.updateY();
}
}
updateY() {
this.mesh.place.y = this.index * 0.5;
}
We transfer the mesh
alongside the y-axis based mostly on its index
and multiply it by 0.5
for now to create some spacing between the textual content components. Now, we’ve got this:

It’s higher, however we nonetheless can’t learn the textual content correctly.
It seems to be barely rotated alongside the y-axis, so we simply have to invert the y-scaling by doing this:
// textual content.js
updateScale() {
this.mesh.scale.set(this.scale, -this.scale, this.scale);
}
…and now we will lastly see our textual content correctly! Issues are shifting in the proper path.

Customized scroll
Let’s implement our scroll conduct so we will view every rendered textual content factor. I may have used varied libraries like Lenis
or Digital Scroll
, however I desire having full management over the performance. So, we’ll implement a customized scroll system inside our 3D area.
Again in our Canvas
class, we’ve got already arrange occasion listeners for wheel
and contact
occasions and applied our scroll logic. Now, we have to move this data to our Gallery
class.
// canvas.js
onTouchDown(occasion) {
this.isDown = true;
this.y.begin = occasion.touches ? occasion.touches[0].clientY : occasion.clientY;
if (this.gallery) this.gallery.onTouchDown({ y: this.y.begin });
}
onTouchMove(occasion) {
if (!this.isDown) return;
this.y.finish = occasion.touches ? occasion.touches[0].clientY : occasion.clientY;
if (this.gallery) this.gallery.onTouchMove({ y: this.y });
}
onTouchUp(occasion) {
this.isDown = false;
this.y.finish = occasion.changedTouches
? occasion.changedTouches[0].clientY
: occasion.clientY;
if (this.gallery) this.gallery.onTouchUp({ y: this.y });
}
onWheel(occasion) {
if (this.gallery) this.gallery.onWheel(occasion);
}
We hold observe of our scroll and move this.y
, which accommodates the beginning, finish, and distance of our scroll alongside the y-axis. For the wheel
occasion, we normalize the occasion values to make sure consistency throughout all browsers after which move them on to our Gallery
class.
Now, in our Gallery
class, we will put together our scroll logic by defining some essential variables.
// gallery.js
this.y = {
present: 0,
goal: 0,
lerp: 0.1,
};
this.scrollCurrent = {
y: 0,
// x: 0
};
this.scroll = {
y: 0,
// x: 0
};
this.y
accommodates the present
, goal
, and lerp
properties, permitting us to clean out the scroll utilizing linear interpolation.
Since we’re passing information from each the contact
and wheel
occasions within the Canvas
class, we have to embody the identical strategies in our Gallery
class and deal with the mandatory calculations for each scrolling and contact motion.
// gallery.js
onTouchDown({ y }) {
this.scrollCurrent.y = this.scroll.y;
}
onTouchMove({ y }) {
const yDistance = y.begin - y.finish;
this.y.goal = this.scrollCurrent.y - yDistance;
}
onTouchUp({ y }) {}
onWheel({ pixelY }) {
this.y.goal -= pixelY;
}
Now, let’s clean the scrolling impact to create a extra pure really feel through the use of the lerp
operate in our replace
methodology:
// gallery.js
replace() {
this.y.present = lerp(this.y.present, this.y.goal, this.y.lerp);
this.scroll.y = this.y.present;
}
Now that we’ve got a correctly clean scroll, we have to move the scroll worth to every textual content factor to replace their place accordingly, like this:
// gallery.js
replace() {
this.y.present = lerp(this.y.present, this.y.goal, this.y.lerp);
this.scroll.y = this.y.present;
this.texts.map((textual content) =>
textual content.replace(this.scroll)
);
}
Now, we additionally want so as to add an replace
methodology within the Textual content
class to retrieve the scroll place and apply it to the mesh place.
// textual content.js
updateY(y = 0) {
this.mesh.place.y = this.index * 0.5 - y;
}
replace(scroll) {
if (this.mesh) {
this.updateY(scroll.y * 0.005);
}
}
We obtain the scroll place alongside the y-axis based mostly on the quantity scrolled utilizing the wheel
occasion and move it to the updateY
methodology. For now, we multiply it by a hardcoded worth to stop the values from being too massive. Then, we subtract it from our mesh place, and we lastly obtain this end result:
Circle it
Now the enjoyable half begins! Since we would like a round format, it’s time to make use of some trigonometry to place every textual content factor round a circle. There are in all probability a number of approaches to attain this, and a few may be easier, however I’ve give you a pleasant methodology based mostly on mathematical calculations. Let’s begin by rotating the textual content components alongside the Z-axis to type a full circle. First, we have to outline some variables:
// textual content.js
this.numberOfText = this.size;
this.angleCalc = ((this.numberOfText / 10) * Math.PI) / this.numberOfText;
Let’s break it down to know the calculation:
We wish to place every textual content factor evenly round a circle. A full circle has an angle of 2π radians (equal to 360 levels).
Since we’ve got this.numberOfText
textual content components to rearrange, we have to decide the angle every textual content ought to occupy on the circle.
So we’ve got:
- The complete circle angle: 360° (or 2π radians).
- The area every textual content occupies: To evenly distribute the texts, we divide the circle into equal components based mostly on the entire variety of texts.
So, the angle every textual content will occupy is the entire angle of the circle (2π radians, written as 2 * Math.PI
) divided by the variety of texts. This offers us the fundamental angle:
this.angleCalc = (2 * Math.PI) / this.numberOfText;
However we’re doing one thing barely completely different right here:
this.angleCalc = ((this.numberOfText / 10) * Math.PI) / this.numberOfText;
What we’re doing right here is adjusting the entire variety of texts by dividing it by 10, which on this case is similar as our fundamental calculation since we’ve got 20 texts, and 20/10 = 2. Nonetheless, this variety of texts might be modified dynamically.
By scaling our angle this manner, we will management the tightness of the format based mostly on that issue. The aim of dividing by 10 is to make the circle extra unfold out or tighter, relying on our design wants. This supplies a solution to fine-tune the spacing between every textual content.
Lastly, right here’s the important thing takeaway: We calculate how a lot angular area every textual content occupies and tweak it with an element (/ 10
) to regulate the spacing, giving us management over the format’s look. This calculation will later be helpful for positioning our mesh alongside the X and Y axes.
Now, let’s apply an identical calculation for the Z-axis by doing this:
// textual content.js
updateZ() {
this.mesh.rotation.z = (this.index / this.numberOfText) * 2 * Math.PI;
}
We rotate every textual content based mostly on its index, dividing it by the entire variety of texts. Then, we multiply the end result by our rotation angle, which, as defined earlier, is the entire angle of the circle (2 * Math.PI
). This offers us the next end result:

We’re nearly there! We are able to see the start of a round rotation, however we nonetheless have to place the weather alongside the X and Y axes to type a full circle. Let’s begin with the X-axis.
Now, we will use our this.angleCalc
and apply it to every mesh based mostly on its index. Utilizing the trigonometric operate cosine
, we will place every textual content factor across the circle alongside the horizontal axis, like this:
// textual content.js
updateX() {
this.angleX = this.index * this.angleCalc;
this.mesh.place.x = Math.cos(this.angleX);
}
And now we’ve got this end result:

It’s occurring! We’re near the ultimate end result. Now, we have to apply the identical logic to the Y-axis. This time, we’ll use the trigonometric operate sine
to place every textual content factor alongside the vertical axis.
// textual content.js
updateY(y = 0) {
// this.mesh.place.y = this.index * 0.5 - y;
this.angleY = this.index * this.angleCalc;
this.mesh.place.y = Math.sin(this.angleY);
}
And now we’ve got our ultimate end result:

For now, the textual content components are appropriately positioned, however we will’t make the circle spin indefinitely as a result of we have to apply the scroll quantity to the X, Y, and Z positions—simply as we initially did for the Y place alone. Let’s move the scroll.y
worth to the updatePosition
methodology for every textual content factor and see the end result.
// textual content.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.place.x = Math.cos(this.angleX);
}
updateY(y = 0) {
this.angleY = this.index * this.angleCalc - y;
this.mesh.place.y = Math.sin(this.angleY);
}
replace(scroll) {
if (this.mesh) {
this.updateY(scroll.y * 0.005);
this.updateX(scroll.y * 0.005);
this.updateZ(scroll.y * 0.005);
}
}
At the moment, we’re multiplying our scroll place by a hardcoded worth that controls the spiral velocity when scrolling. Within the ultimate code, this worth has been added to our GUI
within the high proper nook, permitting you to tweak it and discover the proper setting in your wants.
At this level, we’ve got achieved a really good impact:
Animate it!
To make the round format extra attention-grabbing, we will make the textual content react to the scroll velocity, making a dynamic impact that resembles a flower, paper folding, or any natural movement utilizing shader
code.
First, we have to calculate the scroll velocity based mostly on the quantity of scrolling and move this worth to our Textual content
class. Let’s outline some variables in the identical approach we did for the scroll:
// gallery.js
this.velocity = {
present: 0,
goal: 0,
lerp: 0.1,
};
We calculate the gap traveled and use linear interpolation once more to clean the worth. Lastly, we move it to our Textual content
class.
// gallery.js
replace() {
this.y.present = lerp(this.y.present, this.y.goal, this.y.lerp);
this.scroll.y = this.y.present;
this.velocity.goal = (this.y.goal - this.y.present) * 0.001;
this.velocity.present = lerp(
this.velocity.present,
this.velocity.goal,
this.velocity.lerp
);
this.texts.map((textual content) =>
textual content.replace(
this.scroll,
this.circleSpeed,
this.velocity.present,
this.amplitude
)
);
}
Since we would like our animation to be pushed by the velocity worth, we have to move it to our vertex
shader. To do that, we create a brand new uniform in our Textual content
class named uSpeed
.
// gallery.js
uniforms: {
// customized
uColorBlack: { worth: new THREE.Vector3(0.133, 0.133, 0.133) },
// velocity
uSpeed: { worth: 0.0 },
uAmplitude: { worth: this.amplitude },
// Widespread
...uniforms.widespread,
// Rendering
...uniforms.rendering,
// Strokes
...uniforms.strokes,
},
Replace it in our replace operate like so:
// gallery.js
replace(scroll, velocity) {
if (this.mesh) {
this.mesh.materials.uniforms.uSpeed.worth = velocity;
this.updateY(scroll.y * this.circleSpeed);
this.updateX(scroll.y * this.circleSpeed);
this.updateZ(scroll.y * this.circleSpeed);
}
}
Now that we’ve got entry to our velocity and have created a brand new uniform, it’s time to move it to our vertex
shader and create the animation.
To realize a clean and visually interesting rotation, we will use a really helpful operate from this Gist (particularly, the 3D model). This operate helps refine our transformations, making our vertex
shader appear to be 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
various vec2 vUv;
various vec2 vLayoutUv;
various vec3 vViewPosition;
various vec3 vNormal;
various float vLineIndex;
various float vLineLettersTotal;
various float vLineLetterIndex;
various float vLineWordsTotal;
various float vLineWordIndex;
various float vWordIndex;
various 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 essential() {
// Varyings
vUv = uv;
vLayoutUv = layoutUv;
vNormal = regular;
vLineIndex = lineIndex;
vLineLettersTotal = lineLettersTotal;
vLineLetterIndex = lineLetterIndex;
vLineWordsTotal = lineWordsTotal;
vLineWordIndex = lineWordIndex;
vWordIndex = wordIndex;
vLetterIndex = letterIndex;
vec4 mvPosition = vec4(place, 1.0);
// Output
mvPosition = modelViewMatrix * mvPosition;
gl_Position = projectionMatrix * mvPosition;
vViewPosition = -mvPosition.xyz;
}
Let’s do that step-by-step. First we move our uSpeed
uniform by declaring it:
uniform float uSpeed;
Then we have to create a brand new vec3 variable referred to as newPosition
which is the same as our ultimate place
as a way to tweak it:
vec3 newPosition = place;
We replace the ultimate vec4 mvPosition
to make use of this newPosition
variable:
vec4 mvPosition = vec4(newPosition, 1.0);
Up to now, nothing has modified visually, however now we will apply results and distortions to our newPosition
, which will probably be mirrored in our textual content. Let’s use the rotate
operate imported from the Gist and see the end result:
newPosition = rotate(newPosition, vec3(0.0, 0.0, 1.0), uSpeed * place.x);
We’re basically utilizing the operate to outline the distortion angle based mostly on the x-position of the textual content. We then multiply this worth by the scroll velocity, which we beforehand declared as a uniform. This offers us the next end result:
As you possibly can see, the impact is just too intense, so we have to multiply it by a smaller quantity and fine-tune it to seek out the proper stability.
Let’s observe our shader coding expertise by including this parameter to the GUI
as a uniform. We’ll create a brand new uniform referred to as uAmplitude
and use it to regulate the depth of the impact:
uniform float uSpeed;
uniform float uAmplitude;
newPosition = rotate(newPosition, vec3(0.0, 0.0, 1.0), uSpeed * place.x * uAmplitude);
We are able to create a variable this.amplitude = 0.004
in our Gallery
class, add it to the GUI
for real-time management, and move it to our Textual content
class as we did earlier than:
// gallery.js
this.amplitude = 0.004;
this.gui.add(this, "amplitude").min(0).max(0.01).step(0.001);
replace() {
this.y.present = lerp(this.y.present, this.y.goal, this.y.lerp);
this.scroll.y = this.y.present;
this.velocity.goal = (this.y.goal - this.y.present) * 0.001;
this.velocity.present = lerp(
this.velocity.present,
this.velocity.goal,
this.velocity.lerp
);
this.texts.map((textual content) =>
textual content.replace(
this.scroll,
this.velocity.present,
this.amplitude
)
);
}
…and in our textual content class:
// textual content.js
replace(scroll, circleSpeed, velocity, amplitude) {
this.circleSpeed = circleSpeed;
if (this.mesh) {
this.mesh.materials.uniforms.uSpeed.worth = velocity;
// our amplitude right here
this.mesh.materials.uniforms.uAmplitude.worth = amplitude;
this.updateY(scroll.y * this.circleSpeed);
this.updateX(scroll.y * this.circleSpeed);
this.updateZ(scroll.y * this.circleSpeed);
}
}
And now, you’ve got the ultimate end result with full management over the impact through the GUI, positioned within the high proper nook:
BONUS: Group positioning and enter animation
As an alternative of holding the circle on the middle, we will transfer it to the left aspect of the display to show solely half of it. This method leaves area on the display, permitting us to synchronize the textual content with pictures, for instance (however that’s for an additional tutorial).
Do not forget that when initializing our 3D scene, we calculated the sizes of our 3D area and saved them in this.sizes
. Since all textual content components are grouped inside a Three.js group, we will transfer all the spiral accordingly.
By dividing the group’s place on the X-axis by 2, we shift it from the middle towards the aspect. We are able to then alter its placement: use a unfavorable worth to maneuver it to the left and a optimistic worth to maneuver it to the proper.
this.group.place.x = -this.sizes.width / 2;
We now have our spiral to the left aspect of the display.

To make the web page entry extra dynamic, we will create an animation the place the group strikes from outdoors the display to its ultimate place whereas spinning barely utilizing GSAP
. Nothing too complicated right here—you possibly can customise it nevertheless you want and use any animation library you favor. I’ve chosen to make use of GSAP
and set off the animation proper after including the group to the scene, like this:
// gallery.js
present() {
this.scene.add(this.group);
this.timeline = gsap.timeline();
this.timeline
.fromTo(
this.group.place,
{
x: -this.sizes.width * 2, // outdoors of the display
},
{
period: 0.8,
ease: easing,
x: -this.sizes.width / 2, // ultimate place
}
)
.fromTo(
this.y,
{
// small calculation to be minimal - 1500 to have no less than a small motion and randomize it to have a distinct impact on each touchdown
goal: Math.min(-1500, -Math.random() * window.innerHeight * 6),
},
{
goal: 0,
period: 0.8,
ease: easing,
},
"<" // on the similar time of the primary animation
);
}
That’s a wrap! We’ve efficiently applied the impact.
The GUI is included within the repository, permitting you to experiment with amplitude and spiral velocity. I’d like to see your creations and the way you construct upon this demo. Be at liberty to ask me any questions or share your experiments with me on Twitter or LinkedIn (I’m extra energetic on LinkedIn).