3D Typing Effects with Three.js

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

One of the ways to generate 2D coordinates inside the shape is with Canvas sampling. So let’s create a <canvas> element, apply some font-related styles to it and make sure the size of <canvas> 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 <canvas>…

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 <canvas> 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 <div> element that has a contenteditable attribute set. Compared to an <input> or a <textarea>, it’s more painful to parse the multi-line string from an editable <div>. But it’s much easier to get an accurate pixel values for the cursor position and the text bounding box.

I won’t go too much into details here. The main idea is to keep the editable <div> focused all the time so that we keep track of whatever the user types there.

<div id="text-input" contenteditable="true" onblur="this.focus()" autofocus></div>

Using the keyup event we parse the string and get the width and height of stringBox from the contenteditable <div>, and then refresh the instanced mesh.

document.addEventListener('keyup', () => { handleInput(); refreshText();
});

While parsing, we replace the inner tags with new lines (this part is specific for <div contenteditable>), and do a few things for usability like disabling empty new lines above and below the text.

Please note that <div contenteditable> and text canvas should have the same CSS properties (font, font size, etc). With the same styles applied, the text is rendered in the very same way on both elements. With that in place, we can take the pixel values from <div contenteditable> (text width, height, cursor position) and use them for the canvas.

const textInputEl = document.querySelector('#text-input');
textInputEl.style.fontSize = textureFontSize + 'px';
textInputEl.style.font = '100 ' + textureFontSize + 'px ' + fontName;
textInputEl.style.lineHeight = 1.1 * textureFontSize + 'px'; // ...
function handleInput() { if (isNewLine(textInputEl.firstChild)) { textInputEl.firstChild.remove(); } if (isNewLine(textInputEl.lastChild)) { if (isNewLine(textInputEl.lastChild.previousSibling)) { textInputEl.lastChild.remove(); } } string = textInputEl.innerHTML .replaceAll("<p>", "\n") .replaceAll("</p>", "") .replaceAll("<div>", "\n") .replaceAll("</div>", "") .replaceAll("<br>", "") .replaceAll("<br/>", "") .replaceAll(" ", " "); stringBox.wTexture = textInputEl.clientWidth; stringBox.wScene = stringBox.wTexture * fontScaleFactor; stringBox.hTexture = textInputEl.clientHeight; stringBox.hScene = stringBox.hTexture * fontScaleFactor; function isNewLine(el) { if (el) { if (el.tagName) { if (el.tagName.toUpperCase() === 'DIV' || el.tagName.toUpperCase() === 'P') { if (el.innerHTML === '<br>' || el.innerHTML === '</br>') { return true; } } } } return false }
}

Once we have the string and the stringBox, we update the instanced mesh.

function refreshText() { sampleCoordinates(); textureCoordinates = textureCoordinates.map(c => { return { x: c.x * fontScaleFactor, y: c.y * fontScaleFactor } }); // This part can be removed as we take text size from editable <div> // const sortedX = textureCoordinates.map(v => v.x).sort((a, b) => (b - a))[0]; // const sortedY = textureCoordinates.map(v => v.y).sort((a, b) => (b - a))[0]; // stringBox.wScene = sortedX; // stringBox.hScene = sortedY;</s> recreateInstancedMesh(); updateParticlesMatrices();
}

Coordinate sampling is the same as before with one difference: we now can create canvas with the exact text size, no extra space to sample.

function sampleCoordinates() { const lines = string.split(`\n`); // This part can be removed as we take text size from editable <div> // const linesMaxLength = [...lines].sort((a, b) => b.length - a.length)[0].length; // stringBox.wTexture = textureFontSize * .7 * linesMaxLength; // stringBox.hTexture = lines.length * textureFontSize; textCanvas.width = stringBox.wTexture; textCanvas.height = stringBox.hTexture; // ...
}

We can’t increase the number of instances for the existing mesh. So the mesh should be recreated every time the text is updated. Although text centering and instances transform is done exactly like before.

// function createInstancedMesh() {
function recreateInstancedMesh() { // Now we need to remove the old Mesh and create a new one every refreshText() call scene.remove(instancedMesh); instancedMesh = new THREE.InstancedMesh(particleGeometry, particleMaterial, textureCoordinates.length); // ...
} function updateParticlesMatrices() { // same as before //... }

Since our text is dynamic and it can get pretty long, let’s make sure the instanced mesh fits the screen:

function refreshText() { // ... makeTextFitScreen();
} function makeTextFitScreen() { const fov = camera.fov * (Math.PI / 180); const fovH = 2 * Math.atan(Math.tan(fov / 2) * camera.aspect); const dx = Math.abs(.55 * stringBox.wScene / Math.tan(.5 * fovH)); const dy = Math.abs(.55 * stringBox.hScene / Math.tan(.5 * fov)); const factor = Math.max(dx, dy) / camera.position.length(); if (factor > 1) { camera.position.x *= factor; camera.position.y *= factor; camera.position.z *= factor; }
}

One more thing to add is a caret (text cursor). It can be a simple 3D box with a size matching the font size.

function init() { // ... const cursorGeometry = new THREE.BoxGeometry(.3, 4.5, .03); cursorGeometry.translate(.5, -2.7, 0) const cursorMaterial = new THREE.MeshNormalMaterial({ transparent: true, }); cursorMesh = new THREE.Mesh(cursorGeometry, cursorMaterial); scene.add(cursorMesh);
}

We gather the position of the caret from our editable <div> in pixels and multiply it by fontScaleFactor, like we do with the bounding box width and height.

function handleInput() { // ... stringBox.caretPosScene = getCaretCoordinates().map(c => c * fontScaleFactor); function getCaretCoordinates() { const range = window.getSelection().getRangeAt(0); const needsToWorkAroundNewlineBug = (range.startContainer.nodeName.toLowerCase() === 'div' && range.startOffset === 0); if (needsToWorkAroundNewlineBug) { return [ range.startContainer.offsetLeft, range.startContainer.offsetTop ] } else { const rects = range.getClientRects(); if (rects[0]) { return [rects[0].left, rects[0].top] } else { // since getClientRects() gets buggy in FF document.execCommand('selectAll', false, null); return [ 0, 0 ] } } }
}

The cursor just needs same centering as our instanced mesh has, and voilà, the 3D caret position is the same as in the the input div.

function refreshText() { // ... updateCursorPosition();
} function updateCursorPosition() { cursorMesh.position.x = -.5 * stringBox.wScene + stringBox.caretPosScene[0]; cursorMesh.position.y = .5 * stringBox.hScene - stringBox.caretPosScene[1];
}

The only thing left is to make the cursor blink when the page (and hence the input element) is focused. The roundPulse function generates the rounded pulse between 0 and 1 from THREE.Clock.getElapsedTime(). We need to update the cursor opacity all the time, so the updateCursorOpacity call goes to the main render loop.

function render() { // ... updateCursorOpacity(); // ...
} let roundPulse = (t) => Math.sign(Math.sin(t * Math.PI)) * Math.pow(Math.sin((t % 1) * 3.14), .2); function updateCursorOpacity() { if (document.hasFocus() && document.activeElement === textInputEl) { cursorMesh.material.opacity = roundPulse(2 * clock.getElapsedTime()); } else { cursorMesh.material.opacity = 0; }
}

Basic animation

Instead of setting the instances transform just on the text update, we can also animate this transform.

To do this, we add an additional array of Particle objects to store the parameters for each instance. We still need the textureCoordinates array to store the 2D coordinates in pixels, but now we remap them to the particles array. And obviously, the particles transform update should happen in the main render loop now.

// ...
let textureCoordinates = [];
let particles = []; function refreshText() { // ... // textureCoordinates are only pixel coordinates, particles is array of data objects particles = textureCoordinates.map(c => new Particle([c.x * fontScaleFactor, c.y * fontScaleFactor]) ); // We call it in the render() loop now // updateParticlesMatrices(); // ...
} 

Each Particle object contains a list of properties and a grow() function that updates some of those properties.

For starters, we define position, rotation and scale. Position would be static for each particle, scale would increase from zero to one when the particle is created, and rotation would be animated all the time.

function Particle([x, y]) { this.x = x; this.y = y; this.z = 0; this.rotationX = Math.random() * 2 * Math.PI; this.rotationY = Math.random() * 2 * Math.PI; this.rotationZ = Math.random() * 2 * Math.PI; this.scale = 0; this.deltaRotation = .2 * (Math.random() - .5); this.deltaScale = .01 + .2 * Math.random(); this.grow = function () { this.rotationX += this.deltaRotation; this.rotationY += this.deltaRotation; this.rotationZ += this.deltaRotation; if (this.scale < 1) { this.scale += this.deltaScale; } }
}
// ...
function updateParticlesMatrices() { let idx = 0; // textureCoordinates.forEach(p => { particles.forEach(p => { // update the particles data p.grow(); // dummy.rotation.set(2 * Math.random(), 2 * Math.random(), 2 * Math.random()); dummy.rotation.set(p.rotationX, p.rotationY, p.rotationZ); dummy.scale.set(p.scale, p.scale, p.scale); dummy.position.set(p.x, stringBox.hScene - p.y, p.z); dummy.updateMatrix(); instancedMesh.setMatrixAt(idx, dummy.matrix); idx ++; }) instancedMesh.instanceMatrix.needsUpdate = true;
}

Typing animation

We already have a nice template by now. But every time the text is updated we recreate all the instances for all the symbols. So every time the text is changed we reset all the properties and animations of all the particles.

Instead, we need to keep the properties and animations for “old” particles. To do so, we need to know if each particle should be recreated or not.

In other words, for each sampled coordinate we need to check if Particle already exists or not. If we found a Particle object with the same X/Y coordinates, we keep it along with all its properties. If there is no existing Particle for the sampled coordinate, we call new Particle() like we did before.

We evolve the sampling function so we don’t only gather the X/Y values and refill textureCoordinates array but also do the following:

  1. Turn one-dimensional array imageData to two-dimensional imageMask array
  2. Go through the existing textureCoordinates array and compare its elements to the imageMask. If coordinate exists, add old property to the coordinate, otherwise add toDelete property.
  3. All the sampled coordinates that were not found in the textureCoordinates, we handle as new coordinate that has X and Y values and old or toDelete properties set to false

It would make sense to simply delete old coordinates that were not found in the new imageMask. But we use a special toDelete property instead to play a fade-out animation for deleted particles first, and actually delete the Particle data only in the next step.

function sampleCoordinates() { // Draw text // ... // Sample coordinates if (stringBox.wTexture > 0) { // Image data to 2d array const imageData = textCtx.getImageData(0, 0, textCanvas.width, textCanvas.height); const imageMask = Array.from(Array(textCanvas.height), () => new Array(textCanvas.width)); for (let i = 0; i < textCanvas.height; i++) { for (let j = 0; j < textCanvas.width; j++) { imageMask[i][j] = imageData.data[(j + i * textCanvas.width) * 4] > 0; } } if (textureCoordinates.length !== 0) { // Clean up: delete coordinates and particles which disappeared on the prev step // We need to keep same indexes for coordinates and particles to reuse old particles properly textureCoordinates = textureCoordinates.filter(c => !c.toDelete); particles = particles.filter(c => !c.toDelete); // Go through existing coordinates (old to keep, toDelete for fade-out animation) textureCoordinates.forEach(c => { if (imageMask[c.y]) { if (imageMask[c.y][c.x]) { c.old = true; if (!c.toDelete) { imageMask[c.y][c.x] = false; } } else { c.toDelete = true; } } else { c.toDelete = true; } }); } // Add new coordinates for (let i = 0; i < textCanvas.height; i++) { for (let j = 0; j < textCanvas.width; j++) { if (imageMask[i][j]) { textureCoordinates.push({ x: j, y: i, old: false, toDelete: false }) } } } } else { textureCoordinates = []; }
}

With old and toDelete properties, mapping texture coordinates to the particles becomes conditional:

function refreshText() { // ... // particles = textureCoordinates.map(c => // new Particle([c.x * fontScaleFactor, c.y * fontScaleFactor]) // ); particles = textureCoordinates.map((c, cIdx) => { const x = c.x * fontScaleFactor; const y = c.y * fontScaleFactor; let p = (c.old && particles[cIdx]) ? particles[cIdx] : new Particle([x, y]); if (c.toDelete) { p.toDelete = true; p.scale = 1; } return p; }); // ... }

The grow() call would not only increase the size of the particle when it’s created. We would also decrease it if the particle meant to be deleted.

function Particle([x, y]) { // ... this.toDelete = false; this.grow = function () { // ... if (this.scale < 1) { this.scale += this.deltaScale; } if (this.toDelete) { this.scale -= this.deltaScale; if (this.scale <= 0) { this.scale = 0; } } }
}

The template is now ready and we can use it to create various effects with only little changes.

Bubbles effect 🫧

See the Pen Bubble Typer Three.js – Demo #2 by Ksenia Kondrashova (@ksenia-k) on CodePen.

Here is the full list of changes I made to make these bubbles based on the template:

  1. Change TorusGeometry to IcosahedronGeometry so each instance is a sphere
  2. Replace MeshNormalMaterial with ShaderMaterial. You can check out the GLSL code in the sandbox above but the shader essentially does this:
    • mix white color and randomized gradient (taken from normal vector), and use the result as sphere color
    • applies transparency in a way to make less transparent outline and more transparent middle of the sphere if you look from the camera position
  3. Adjust textureFontSize and fontScaleFactor values to change the density of the particles
  4. Evolve the Particle object so that
    • bubble position is a bit randomized comparing to the sampled coordinates
    • maximum size of the bubble is defined by randomized maxScale property
    • no rotation
    • bubbles size is randomized as the scale limit is maxScale property, not 1
    • bubble grows all the time, bursts, and then grows again. So the scale increase happens not only when Particle is created but all the time. Once the scale reaches the maxScale value, we reset the scale to zero
    • some bubbles would get isFlying property so they move up from the initial position
  5. Change color of page background and cursor

Clouds effect ☁

You don’t need to do much for having clouds, too:

  1. Use PlaneGeometry for instance shape
  2. Use MeshBasicMaterial and apply the following image as an alpha map
  3. Adjust textureFontSize and fontScaleFactor to change the density of the particles
  4. Evolve the Particle object so that
    • particle position is a bit randomized compared to the sampled coordinates
    • size of the particle is defined by randomized maxScale property
    • only rotation around Z axis is needed
    • particle size (scale) is pulsating all the time
  5. Additional transform dummy.quaternion.copy(camera.quaternion) should be applied for each instance. This way the particle is always facing towards the camera; rotate the cloudy text to see the result 🙂
  6. Change color of page background and cursor

See the Pen Clouds Typer Three.js – Demo #1 by Ksenia Kondrashova (@ksenia-k) on CodePen.

Flowers effect 🌸

Flowers are actually quite similar to clouds. The main difference is about having two instanced meshes and two materials. One is mapped as flower texture, another one as a leaf


Also, all the particles must have a new color property. We apply colors to the instanced mesh with the setColorAt method every time we recreate the meshes.

With a few small changes like particles density, scaling speed, rotation speed, and the color of the background and cursor, we have this:

See the Pen Flower Typer Three.js – Demo #3 by Ksenia Kondrashova (@ksenia-k) on CodePen.

Eyes effect 👀

We can go further and load a glb model and use it as an instance! I took this nice looking eye from turbosquid.com

Instead of applying a random rotation, we can make the eyeballs follow the mouse position! To do so, we need an additional transparent plane in front of the instanced mesh, THREE.Raycaster() and the mouse position tracker. We are listening to the mousemove event, set ray from mouse to the plane, and make the dummy object look at the intersection point.

Don’t forget to add some lights to see the imported model. And as we have lights, let’s make the instanced mesh cast the shadow to the plane behind the text.

Together with some other small changes like sampling density, grow() function parameters, cursor and background style, we get this:

See the Pen Eyes Typer Three.js – Demo #4 by Ksenia Kondrashova (@ksenia-k) on CodePen.

And that’s it! I hope this tutorial was interesting and that it gave you some inspiration. Feel free to use this template to create more fun things!