Building an On-Scroll 3D Circle Text Animation with Three.js and Shaders


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 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).

Recent Articles

Related Stories

Leave A Reply

Please enter your comment!
Please enter your name here