On this tutorial, you’ll learn 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 device to assist rendering textual content in 3D area right here, however you should use every other device 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 primarily based on the pace of the scroll.
Let’s dive in!
Preliminary Setup
Step one is to arrange our 3D surroundings. Nothing fancy right here—it’s a primary Three.js implementation. I simply desire to maintain issues organized, so there’s a principal.js
file the place the whole lot is ready up for all the opposite courses that could be wanted sooner or later. It features a requestAnimationFrame
loop and all vital eventListener
implementations.
// principal.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.aspect = 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.digicam = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
this.digicam.place.z = 5;
}
createRenderer() {
this.renderer = new THREE.WebGLRenderer({
canvas: this.aspect,
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.digicam.facet = window.innerWidth / window.innerHeight;
this.digicam.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const fov = this.digicam.fov * (Math.PI / 180);
const top = 2 * Math.tan(fov / 2) * this.digicam.place.z;
const width = top * this.digicam.facet;
this.sizes = {
width,
top,
};
}
replace() {
this.renderer.render(this.scene, this.digicam);
}
}
Explaining the Canvas
class setup
We begin by creating the scene in createScene()
and storing it in this.scene
so we are able to cross it to our future 3D parts.
We create the digicam within the createCamera()
technique and the renderer in createRenderer()
, passing the canvas aspect and setting some primary choices. I normally have some DOM parts 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:
- Guaranteeing that our
<canvas>
aspect is all the time resized appropriately to match the viewport dimensions. - Updating the
digicam
facet ratio by dividing the viewport width by its top. - Storing our measurement values, which symbolize a metamorphosis primarily based on the digicam’s area of view (FOV) to transform pixels into the 3D surroundings.
Lastly, our replace
technique serves as our requestAnimationFrame
loop, the place we constantly 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 executed it instantly in canva.js
as it’s a small tutorial however I wish to maintain issues individually for future challenge growth.
// 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, digicam, sizes, gui }) {
this.renderer = renderer;
this.scene = scene;
this.digicam = digicam;
this.sizes = sizes;
this.gui = gui;
this.group = new THREE.Group();
this.createText();
this.present();
}
createText() {
this.texts = information.map((aspect, index) => {
return new Textual content({
aspect,
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 digicam to place the whole lot within the 3D area.
We create a bunch utilizing new THREE.Group()
to handle our assortment of textual content extra simply. Every textual content aspect will likely be generated primarily based 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 are going to create our Textual content
class, however earlier than that, we have to arrange our gallery throughout the Canvas
class. To do that, we add a createGallery
technique and cross it the mandatory data.
// gallery.js
createGallery() {
this.gallery = new Gallery({
renderer: this.renderer,
scene: this.scene,
digicam: this.digicam,
sizes: this.sizes,
gui: this.gui,
});
}
Don’t overlook to name the identical technique from the Canvas
class to the Gallery
class to keep up constant data throughout our app.
// gallery.js
onResize() {
this.digicam.facet = window.innerWidth / window.innerHeight;
this.digicam.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const fov = this.digicam.fov * (Math.PI / 180);
const top = 2 * Math.tan(fov / 2) * this.digicam.place.z;
const width = top * this.digicam.facet;
this.sizes = {
width,
top,
};
if (this.gallery)
this.gallery.onResize({
sizes: this.sizes,
});
}
replace() {
if (this.gallery) this.gallery.replace();
this.renderer.render(this.scene, this.digicam);
}
Now, let’s create our array of texts that we wish to use in our gallery. We are going to outline a createText
technique and use .map
to generate new situations of the Textual content
class (new Textual content()
), which is able to symbolize every textual content aspect within the gallery.
// gallery.js
createText() {
this.texts = information.map((aspect, index) => {
return new Textual content({
aspect,
scene: this.group,
sizes: this.sizes,
size: information.size,
index,
});
});
}
Introducing three-msdf-text-utils
To render our textual content in 3D area, we are going to use three-msdf-text-utils. For this, we’d like a bitmap font and a font atlas, which we are able to generate utilizing the msdf-bmfont on-line device. 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, you want to outline the character set for the font. Be sure that to incorporate each letter—each uppercase and lowercase—together with each quantity if you would 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 Ok 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 will then comply with the documentation to render our textual content, however we might want to tweak just a few 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.
- Embrace shader code from the documentation to permit us so as to add customized results later.
To load the font, we are going to 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 likely 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.aspect.title,
font: fnt,
});
const materials = new THREE.ShaderMaterial({
facet: THREE.DoubleSide,
opacity: 0.5,
clear: true,
defines: {
IS_SMALL: false,
},
extensions: {
derivatives: true,
},
uniforms: {
// Frequent
...uniforms.frequent,
// 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.aspect.title
from our array) and the font.
Subsequent, we create our materials primarily based 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 instantly. Nevertheless, 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 likely be helpful later after 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: Frequent
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 principal() {
// Frequent
// 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 Check
if (alpha < uAlphaTest) discard;
// Output: Frequent
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 principal() {
// 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 size of our mesh and open our browser to lastly see one thing on the display screen. We are going to begin with a scale of 0.008
and apply it to our mesh. Up to now, the Textual content.js
file seems 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({ aspect, scene, sizes, index, size }) {
this.aspect = aspect;
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.aspect.title,
font: fnt,
});
const materials = new THREE.ShaderMaterial({
facet: THREE.DoubleSide,
opacity: 0.5,
clear: true,
defines: {
IS_SMALL: false,
},
extensions: {
derivatives: true,
},
uniforms: {
// Frequent
...uniforms.frequent,
// 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 challenge to see the end result:

We will 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 virtually black shade. three-msdf
offers a uColor
uniform, however let’s apply our GLSL
expertise and add our personal uniform manually.
We will introduce a brand new uniform referred to as uColorBack
, which will likely be a Vector3
representing a black shade #222222
. Nevertheless, 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) },
// Frequent
...uniforms.frequent,
// Rendering
...uniforms.rendering,
// Strokes
...uniforms.strokes,
},
However this isn’t sufficient—we additionally must cross the uniform to our fragment
shader and use it as a substitute of the default uColor
:
// shaders/text-fragment.glsl
uniform vec3 uColorBlack;
// Output: Frequent
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 is going to look higher quickly! First, let’s create some area between the textual content parts so we are able to see them correctly. We’ll add a this.updateY
technique to place every textual content aspect appropriately primarily based 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 primarily based on its index
and multiply it by 0.5
for now to create some spacing between the textual content parts. 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 must invert the y-scaling by doing this:
// textual content.js
updateScale() {
this.mesh.scale.set(this.scale, -this.scale, this.scale);
}
…and now we are able to lastly see our textual content correctly! Issues are shifting in the suitable path.

Customized scroll
Let’s implement our scroll conduct so we are able to view every rendered textual content aspect. 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 carried out our scroll logic. Now, we have to cross 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 maintain monitor of our scroll and cross this.y
, which incorporates 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 cross them on to our Gallery
class.
Now, in our Gallery
class, we are able to put together our scroll logic by defining some vital 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
incorporates 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 by utilizing the lerp
operate in our replace
technique:
// 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 cross the scroll worth to every textual content aspect 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
technique 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 primarily based on the quantity scrolled utilizing the wheel
occasion and cross it to the updateY
technique. For now, we multiply it by a hardcoded worth to forestall the values from being too giant. Then, we subtract it from our mesh place, and we lastly obtain this end result:
Circle it
Now the enjoyable half begins! Since we wish a round structure, it’s time to make use of some trigonometry to place every textual content aspect round a circle. There are most likely a number of approaches to attain this, and a few is likely to be easier, however I’ve provide you with a pleasant technique primarily based on mathematical calculations. Let’s begin by rotating the textual content parts alongside the Z-axis to kind 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 grasp the calculation:
We wish to place every textual content aspect 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 parts to rearrange, we have to decide the angle every textual content ought to occupy on the circle.
So we’ve got:
- The total circle angle: 360° (or 2π radians).
- The area every textual content occupies: To evenly distribute the texts, we divide the circle into equal elements primarily based 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 provides us the essential angle:
this.angleCalc = (2 * Math.PI) / this.numberOfText;
However we’re doing one thing barely totally 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 identical as our primary calculation since we’ve got 20 texts, and 20/10 = 2. Nevertheless, this variety of texts might be modified dynamically.
By scaling our angle this manner, we are able to management the tightness of the structure primarily based 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 offers 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 structure’s look. This calculation will later be helpful for positioning our mesh alongside the X and Y axes.
Now, let’s apply the same 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 primarily based 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 provides us the next end result:

We’re virtually there! We will see the start of a round rotation, however we nonetheless must place the weather alongside the X and Y axes to kind a full circle. Let’s begin with the X-axis.
Now, we are able to use our this.angleCalc
and apply it to every mesh primarily based on its index. Utilizing the trigonometric operate cosine
, we are able to place every textual content aspect 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 aspect 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 last end result:

For now, the textual content parts are appropriately positioned, however we are able to’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 cross the scroll.y
worth to the updatePosition
technique for every textual content aspect 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 pace when scrolling. Within the last 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 structure extra fascinating, we are able to make the textual content react to the scroll pace, making a dynamic impact that resembles a flower, paper folding, or any natural movement utilizing shader
code.
First, we have to calculate the scroll pace primarily based on the quantity of scrolling and cross this worth to our Textual content
class. Let’s outline some variables in the identical means we did for the scroll:
// gallery.js
this.pace = {
present: 0,
goal: 0,
lerp: 0.1,
};
We calculate the space traveled and use linear interpolation once more to clean the worth. Lastly, we cross 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.pace.goal = (this.y.goal - this.y.present) * 0.001;
this.pace.present = lerp(
this.pace.present,
this.pace.goal,
this.pace.lerp
);
this.texts.map((textual content) =>
textual content.replace(
this.scroll,
this.circleSpeed,
this.pace.present,
this.amplitude
)
);
}
Since we wish our animation to be pushed by the pace worth, we have to cross 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) },
// pace
uSpeed: { worth: 0.0 },
uAmplitude: { worth: this.amplitude },
// Frequent
...uniforms.frequent,
// Rendering
...uniforms.rendering,
// Strokes
...uniforms.strokes,
},
Replace it in our replace operate like so:
// gallery.js
replace(scroll, pace) {
if (this.mesh) {
this.mesh.materials.uniforms.uSpeed.worth = pace;
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 pace and have created a brand new uniform, it’s time to cross it to our vertex
shader and create the animation.
To realize a clean and visually interesting rotation, we are able to use a really helpful operate from this Gist (particularly, the 3D model). This operate helps refine our transformations, making our vertex
shader seem 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
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 principal() {
// 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 cross 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 last 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 are able to apply results and distortions to our newPosition
, which will likely 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 primarily based on the x-position of the textual content. We then multiply this worth by the scroll pace, which we beforehand declared as a uniform. This provides us the next end result:
As you possibly can see, the impact is simply too intense, so we have to multiply it by a smaller quantity and fine-tune it to search out the proper steadiness.
Let’s apply 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 manage 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 will create a variable this.amplitude = 0.004
in our Gallery
class, add it to the GUI
for real-time management, and cross 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.pace.goal = (this.y.goal - this.y.present) * 0.001;
this.pace.present = lerp(
this.pace.present,
this.pace.goal,
this.pace.lerp
);
this.texts.map((textual content) =>
textual content.replace(
this.scroll,
this.pace.present,
this.amplitude
)
);
}
…and in our textual content class:
// textual content.js
replace(scroll, circleSpeed, pace, amplitude) {
this.circleSpeed = circleSpeed;
if (this.mesh) {
this.mesh.materials.uniforms.uSpeed.worth = pace;
// 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 have got the ultimate end result with full management over the impact by way of the GUI, positioned within the high proper nook:
BONUS: Group positioning and enter animation
As a substitute of maintaining the circle on the middle, we are able to transfer it to the left facet of the display screen to show solely half of it. This method leaves area on the display screen, permitting us to synchronize the textual content with photographs, for instance (however that’s for one more tutorial).
Keep in mind that when initializing our 3D scene, we calculated the sizes of our 3D area and saved them in this.sizes
. Since all textual content parts are grouped inside a Three.js group, we are able to transfer your complete spiral accordingly.
By dividing the group’s place on the X-axis by 2, we shift it from the middle towards the facet. We will then regulate its placement: use a damaging worth to maneuver it to the left and a optimistic worth to maneuver it to the suitable.
this.group.place.x = -this.sizes.width / 2;
We now have our spiral to the left facet of the display screen.

To make the web page entry extra dynamic, we are able to create an animation the place the group strikes from exterior the display screen to its last place whereas spinning barely utilizing GSAP
. Nothing too complicated right here—you possibly can customise it nonetheless 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, // exterior of the display screen
},
{
length: 0.8,
ease: easing,
x: -this.sizes.width / 2, // last place
}
)
.fromTo(
this.y,
{
// small calculation to be minimal - 1500 to have not 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,
length: 0.8,
ease: easing,
},
"<" // on the similar time of the primary animation
);
}
That’s a wrap! We’ve efficiently carried out the impact.
The GUI is included within the repository, permitting you to experiment with amplitude and spiral pace. 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).