3D Typing Effects with Three.js

From our sponsor: B26953268 3D Typing Effects with Three.jsSell access to courses, classes, and community with Squarespace.

In this tutorial we’ll explore various animated WebGL text typing effects. We will mostly be using Three.js but not the whole tutorial relies on the specific features of this library.
But who doesn’t love Three.js though ❤️

This tutorial is aimed at developers who are familiar with the basic concepts of WebGL.

The main idea is to create a JavaScript template that takes a keyboard input and draws the text on the screen in some fancy way. The effects we will build today are all about composing a text shape with a big number of repeating objects. We will cover the following steps:

  • Sampling text on Canvas (generating 2D coordinates)
  • Setting up the scene and placing the Canvas element
  • Generating particles in 3D space
  • Turning particles to an instanced mesh
  • Replacing a static string with some user input
  • Basic animation
  • Typing-related animation
  • Generating the visuals: clouds, bubbles, flowers, eyeballs

Text sampling

In the following we will fill a text shape with some particles.

First, let’s think about what a 3D text shape is. In general, a text mesh is nothing but a 2D shape being extruded. So we don’t need to sample the 3rd coordinate – we can just use X/Y coordinates with Z being randomly generated within the text depth (although we’re not about to use the Z coordinate much today).

3d text 3D Typing Effects with Three.js

One of the ways to generate 2D coordinates inside the shape is with Canvas sampling. So let’s create a element, apply some font-related styles to it and make sure the size of is big enough for the text to fit (extra space is okay).

// Settings
const fontName = 'Verdana';
const textureFontSize = 100;

// String to show
let string = 'Some text' + '\n' + 'to sample' + '\n' + 'with Canvas';

// Create canvas to sample the text
const textCanvas = document.createElement('canvas');
const textCtx = textCanvas.getContext('2d');
document.body.appendChild(textCanvas);

// ---------------------------------------------------------------

sampleCoordinates();

// ---------------------------------------------------------------

function sampleCoordinates() 

    // Parse text
    const lines = string.split(`\n`);
    const linesMaxLength = [...lines].sort((a, b) => b.length - a.length)[0].length;
    const wTexture = textureFontSize * .7 * linesMaxLength;
    const hTexture = lines.length * textureFontSize;

    // ...

With the Canvas API you can set all the font styling pretty much like in CSS. Custom fonts can be used as well, but I’m using good old Verdana today.

Once the style is set, we draw the text (or any other graphics!) on the

function sampleCoordinates() 

    // Parse text
    // ...

    // Draw text
    const linesNumber = lines.length;
    textCanvas.width = wTexture;
    textCanvas.height = hTexture;
    textCtx.font = '100 ' + textureFontSize + 'px ' + fontName;
    textCtx.fillStyle = '#2a9d8f';
    textCtx.clearRect(0, 0, textCanvas.width, textCanvas.height);
    for (let i = 0; i < linesNumber; i++) 
        textCtx.fillText(lines[i], 0, (i + .8) * hTexture / linesNumber);
    

    // ...

… to be able to get imageData from it.

The ImageData object contains a one-dimensional array with RGBA data for every pixel. Knowing the size of the canvas, we can go through the array and check if the given X/Y coordinate matches the color of text or the color of the background.

Since our canvas doesn’t have anything but colored text on the unset (transparent black) background, we can check any of the four RGBA bytes with against a condition as simple as “bigger than zero”.

function sampleCoordinates() {
    // Parse text
    // ...
    // Draw text
    // ...
    // Sample coordinates
    textureCoordinates = [];
    const samplingStep = 4;
    if (wTexture > 0) {
        const imageData = textCtx.getImageData(0, 0, textCanvas.width, textCanvas.height);
        for (let i = 0; i < textCanvas.height; i += samplingStep) 
            for (let j = 0; j < textCanvas.width; j += samplingStep) 
                // Checking if R-channel is not zero since the background RGBA is (0,0,0,0)
                if (imageData.data[(j + i * textCanvas.width) * 4] > 0) 
                    textureCoordinates.push(
                        x: j,
                        y: i
                    )
                
            
        
    }
}

There’re lots of things you can do with the sampling function: change the sampling step, add some randomness, apply an outline stroke to the text, and more. Below we’ll keep using only the simplest sampling. To check the result we can add a second and draw the dot for each of sampled textureCoordinates.

It works 🙂

The Three.js scene

Let’s set up a basic Three.js scene and place a Plane object on it. We can use the text sampling Canvas from the previous step as a color map for the Plane.

Generating the particles

We can generate 3D coordinates with the very same sampling function. X/Y are gathered from the Canvas and for the Z coordinate we can take a random number.

The easiest way to visualize this set of coordinates would be a particle system known as THREE.Points.

function createParticles() 
    const geometry = new THREE.BufferGeometry();
    const material = new THREE.PointsMaterial(
        color: 0xff0000,
        size: 2
    );
    const vertices = [];
    for (let i = 0; i < textureCoordinates.length; i ++) 
        vertices.push(textureCoordinates[i].x, textureCoordinates[i].y, 5 * Math.random());
    
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
    const particles = new THREE.Points(geometry, material);
    scene.add(particles);

Somehow it works ¯\_(ツ)_/¯

Obviously, we need to flip the Y coordinate for each particle and center the whole text.

To do both, we need to know the bounding box of our text. There are various ways to measure the box using the canvas API or Three.js functions. But as a temporary solution, we just take max X and Y coordinates as width and height of the text.

function refreshText() 
    sampleCoordinates();
    
    // Gather with and height of the bounding box
    const maxX = textureCoordinates.map(v => v.x).sort((a, b) => (b - a))[0];
    const maxY = textureCoordinates.map(v => v.y).sort((a, b) => (b - a))[0];
    stringBox.wScene = maxX;
    stringBox.hScene = maxY;

    createParticles();

For each point, the Y coordinate becomes boxTotalHeight - Y.

Shifting the whole particles system by half-width and half-height of the box solves the centering issue.

function createParticles() 
    
    // ...
    for (let i = 0; i < textureCoordinates.length; i ++) 
       // Turning Y coordinate to stringBox.hScene - Y
       vertices.push(textureCoordinates[i].x, stringBox.hScene - textureCoordinates[i].y, 5 * Math.random());
    
    // ...
    
    // Centralizing the text
    particles.position.x = -.5 * stringBox.wScene;
    particles.position.y = -.5 * stringBox.hScene;

Until now, we were using pixel coordinates gathered from text canvas directly on the 3D scene. But let’s say we need the 3D text to have the height equal to 10 units. If we set 10 as a font size, the canvas resolution would be too low to make a proper sampling. To avoid it (and to be more flexible with the particles density), we can add an additional scaling factor: the value we’d multiply the canvas coordinates with before using them in 3D space.

// Settings
// ...
const textureFontSize = 30;
const fontScaleFactor = .3;

// ...

function refreshText() 

    // ...

    textureCoordinates = textureCoordinates.map(c => 
        return  x: c.x * fontScaleFactor, y: c.y * fontScaleFactor 
    );
    
    // ...


At this point, we can also remove the Plane object. We keep using the canvas to draw the text and sample coordinates but we don’t need to turn it to a texture and put it on the scene.

Switching to instanced mesh

Of course there are many cool things we can do with THREE.Points but our next step is turning the particles into THREE.InstancedMesh.

The main limitation of THREE.Points is the particle size. THREE.PointsMaterial is based on WebGL gl_PointSize, which can be rendered with a maximum pixel size of around 50 to 100, depending on your video card. So even if we need our particles to be as simple as planes, we sometimes can’t use THREE.Points due to this limitation. You may think about THREE.Sprite as an alternative, but (surprisingly) instanced mesh gives us much better performance on the big (10k+) number of particles.

Plus, if we want to use 3D shapes as particles, THREE.InstancedMesh is the only choice.

There is a well-known approach to work with THREE.InstancedMesh:

  1. Create an instanced mesh with a known number of instances. In our case, the number of instances is the length of our coordinates array.
function createInstancedMesh() 
    instancedMesh = new THREE.InstancedMesh(particleGeometry, particleMaterial, textureCoordinates.length);
    scene.add(instancedMesh);

    // centralize it in the same way as before
    instancedMesh.position.x = -.5 * stringBox.wScene;
    instancedMesh.position.y = -.5 * stringBox.hScene;
  1. Add the geometry and material to be used for each instance. I use a doughnut shape known as THREE.TorusGeometry and THREE.MeshNormalMaterial.
function init() 
    // Create scene and text canvas
    // ...

    // Instanced geometry and material
    particleGeometry = new THREE.TorusGeometry(.1, .05, 16, 50);
    particleMaterial = new THREE.MeshNormalMaterial( );

    // ...
  1. Create a dummy object that helps us generate a 4×4 transform matrix for each particle. It doesn’t need to be a part of the scene.
function init() 
    // Create scene, text canvas, instanced geometry and material
    // ...

    dummy = new THREE.Object3D();
  1. Apply the transform matrix to each instance with the .setMatrixAt method
function updateParticlesMatrices() 
    let idx = 0;
    textureCoordinates.forEach(p => 

        // we apply samples coordinates like before + some random rotation
        dummy.rotation.set(2 * Math.random(), 2 * Math.random(), 2 * Math.random());
        dummy.position.set(p.x, stringBox.hScene - p.y, Math.random());

        dummy.updateMatrix();
        instancedMesh.setMatrixAt(idx, dummy.matrix);

        idx ++;
    )
    instancedMesh.instanceMatrix.needsUpdate = true;

Listening to the keyboard

So far, the string value was hard-coded. We want it to be dynamic and contain the user input.

There are many ways to listen to the keyboard: working directly with keyup/keydown events, using the HTML input element as a proxy, etc. I ended up with a

element that has a contenteditable attribute set. Compared to an or a