Erratic Generator

Creative Coding and Design

About

Animate Word by Word in p5.js

Typography animation using class & objects

Word-level typography animation with p5.js. Image by Author.

In this post, I would like to show you how to make the text animation where you can have control over individual words. We will also store its original position so that you can return back. We will use a JavaScript library called p5.js. If you are not familiar with the library, check out their website. It is designed to be beginner-friendly for creating audio-visual-interaction on the web.

Here is a breakdown on what we will look at. First, we will write down some text but instead of storing in a single string we will break them down into individual words so that our program will remember its original position. We will store each word along with its position in JS objects. That’s where we will also implement methods for animation.

Display Individual Words

p5.js has the text() where you can pass the message and coordinates to display on the canvas. For example, this will display ‘Hello, world!’ on screen:

const str = 'Hello, world!'
text(str, 20, 20)

If you want to animate the whole phrase, this is fine, but if you want to animate each word differently, we will need to use a different data structure since we don’t know exactly at what pixel coordinate the second word will start. We can split the string whenever there is an empty space between words and store them in an array. This is easy with the split() function:

const wordsStr = str.split(' ')

We will then iterate over each word and determine the position of the next words. How to know where to place the next words? p5js has the textWidth() function, which will return the width of any text given in pixels. Note that this width value is dependent on the current text size. Let’s look at what we have so far:

function setup() {
createCanvas(800, 400)
background(0)
const str = 'Hello, world! Hi, there! Nice to meet you!'
const wordsStr = str.split(' ')
textSize(48)
// track word position
let x = 20
let y = 60
fill(255)
// iterate over each word
for (let i = 0; i < wordsStr.length; i++) {
const wordStr = wordsStr[i] // get current word
const wordStrWidth = textWidth(wordStr) // get current word width
text(wordStr, x, y) // display word
x = x + wordStrWidth + textWidth(' ') // update x by word width + space character
}
}

It doesn’t look like much, but now we broke the whole phrase into individual words. Image by Author.
It doesn’t look like much, but now we broke the whole phrase into individual words. Image by Author.

This is going great, but we have a problem that when the text is too long, it goes beyond the screen width. To fix that, we need to check the next word’s width and if it won’t fit in the remaining space, we will need to do a line break.

// in the same for loop
x = x + wordStrWidth + textWidth(' ')
// look ahead the next word - will it fit in the space? if not, line break
const nextWordStrWidth = textWidth(wordsStr[i+1]) || 0
if (x > width - nextWordStrWidth) {
    y += 40 // line height, sort of
    x = 20 // reset x position
}

Word Class and objects

The next step is to store the x and y positions of each word so that we can retrieve them later in our program. Right now, these x and y values are only local variables, so it is difficult to use them again in other functions. So, we will create a class Word for each word, and inside, we can store the word text itself as well as x/y values and more. Here is the definition of that class:

class Word {
    constructor(word, x, y, idx) {
        this.word = word
        this.x = x
        this.y = y
        this.idx = idx
        this.fcolor = color(255)
    }
display() {
        fill(this.fcolor)
        noStroke()
        text(this.word, this.x, this.y)
    }
}

This is very basic, but we will add more methods a little later. We also store the index value as this may come in handy in the future. Now, instead of directly using the text() function, let’s create objects out of this class and use the word.display() method:

const words = [] // store word objects
function setup() {
createCanvas(800, 400)
background(0)
const str = 'Hello, world! Hi, there! Nice to meet you!'
const wordsStr = str.split(' ')
textSize(48)
// track word position
let x = 20
let y = 60
fill(255)
// iterate over each word
for (let i = 0; i < wordsStr.length; i++) {
const wordStr = wordsStr[i] // get current word
const wordStrWidth = textWidth(wordStr) // get current word width
const word = new Word(wordStr, x, y, i)
words.push(word)
x = x + wordStrWidth + textWidth(' ') // update x by word width + space character
// look ahead the next word - will it fit in the space? if not, line break
const nextWordStrWidth = textWidth(wordsStr[i+1]) || 0
if (x > width - nextWordStrWidth) {
y += 40 // line height, sort of
x = 20 // reset x position
}
}
for (let i = 0; i < words.length; i++) {
const word = words[i] // retrive word object
word.display()
}
}

All the data we need is now part of word objects, and we can retrieve its values anytime we need. Try changing the str text message itself, and it will nicely adapt to the new text at any length.

Animate Each Word

Because we now have a good structure, adding animation will be just adding more methods to the Word class. We will create an update() method, which will be run every frame in the main draw() loop. And for animation, I will use lerp() function and move the words 10% closer to our target each frame:

this.x = lerp(this.x, this.tx, 0.1)
this.y = lerp(this.y, this.ty, 0.1)

Of course, this.tx and this.ty are new variables, so we will have to define it in the constructor. In the beginning, this.x and this.tx will be the same, so there will be no animation as the target is the same as the current position, but I will randomize the target position whenever we press the mouse. Here is the implementation:

const words = [] // store word objects
function setup() {
createCanvas(800, 400)
background(0)
const str = 'Hello, world! Hi, there! Nice to meet you!'
const wordsStr = str.split(' ')
textSize(48)
// track word position
let x = 20
let y = 60
fill(255)
// iterate over each word
for (let i = 0; i < wordsStr.length; i++) {
const wordStr = wordsStr[i] // get current word
const wordStrWidth = textWidth(wordStr) // get current word width
const word = new Word(wordStr, x, y, i)
words.push(word)
x = x + wordStrWidth + textWidth(' ') // update x by word width + space character
// look ahead the next word - will it fit in the space? if not, line break
const nextWordStrWidth = textWidth(wordsStr[i+1]) || 0
if (x > width - nextWordStrWidth) {
y += 40 // line height, sort of
x = 20 // reset x position
}
}
}
function draw() {
background(0)
for (let i = 0; i < words.length; i++) {
const word = words[i] // retrieve word object
word.update()
word.display()
}
}
function mousePressed() {
for (let i = 0; i < words.length; i++) {
const word = words[i]
word.spread()
}
}
class Word {
constructor(word, x, y, idx) {
this.word = word
this.x = x
this.y = y
// target position is the same as current position at start
this.tx = this.x
this.ty = this.y
this.idx = idx
this.fcolor = color(255)
}
spread() {
this.tx = random(width)
this.ty = random(height)
}
update() {
// move towards the target by 10% each time
this.x = lerp(this.x, this.tx, 0.1)
this.y = lerp(this.y, this.ty, 0.1)
}
display() {
fill(this.fcolor)
noStroke()
text(this.word, this.x, this.y)
}
}

Each more press will randomize the word position. Image by Author.
Each more press will randomize the word position. Image by Author.

Bring Them Back

One last thing to do is to bring all the words back to where it started. We need to update this.x and this.y to be able to animate the words, so the moment it starts to animate, the word object loses its original position. We will need to create new variables so each word will always remember its original position.

// in Word class
constructor(word, x, y, idx) {
    this.word = word
    this.x = x
    this.y = y
    // target position is the same as current position at start
    this.tx = this.x
    this.ty = this.y
    // original position
    this.origx = this.x
    this.origy = this.y
    this.idx = idx
    this.fcolor = color(255)
}

Let’s also add one more method in the Word class to reset x and y positions to their original values:

// in Word class
reset() {
    this.tx = this.origx
    this.ty = this.origy
}

Now, we have multiple actions to control, I am moving some parts of the code from mousePressed() to keyPressed().

function keyPressed() {
    if (key === 'r') {
        for (let word of words) word.spread()
    } else if (key === ' ') {
        for (let word of words) word.reset()
    }
}

And finally, here is the full implementation.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/p5@1.1.9/lib/p5.js"></script>
</head>
<body>
<script>
const words = [] // store word objects
function setup() {
createCanvas(800, 400)
background(0)
const str = 'Hello, world! Hi, there! Nice to meet you!'
const wordsStr = str.split(' ')
textSize(48)
// track word position
let x = 20
let y = 60
fill(255)
// iterate over each word
for (let i = 0; i < wordsStr.length; i++) {
const wordStr = wordsStr[i] // get current word
const wordStrWidth = textWidth(wordStr) // get current word width
const word = new Word(wordStr, x, y, i)
words.push(word)
x = x + wordStrWidth + textWidth(' ') // update x by word width + space character
// look ahead the next word - will it fit in the space? if not, line break
const nextWordStrWidth = textWidth(wordsStr[i+1]) || 0
if (x > width - nextWordStrWidth) {
y += 40 // line height, sort of
x = 20 // reset x position
}
}
}
function draw() {
background(0)
for (let i = 0; i < words.length; i++) {
const word = words[i] // retrieve word object
word.update()
word.display()
}
}
function keyPressed() {
if (key === 'r') {
for (let word of words) word.spread()
} else if (key === ' ') {
for (let word of words) word.reset()
}
}
class Word {
constructor(word, x, y, idx) {
this.word = word
this.x = x
this.y = y
// target position is the same as current position at start
this.tx = this.x
this.ty = this.y
// original position
this.origx = this.x
this.origy = this.y
this.idx = idx
this.fcolor = color(255)
}
reset() {
this.tx = this.origx
this.ty = this.origy
}
spread() {
this.tx = random(width)
this.ty = random(height)
}
update() {
// move towards the target by 10% each time
this.x = lerp(this.x, this.tx, 0.1)
this.y = lerp(this.y, this.ty, 0.1)
}
display() {
fill(this.fcolor)
noStroke()
text(this.word, this.x, this.y)
}
}
</script>
</body>
</html>

Wrap-up

I hope my breakdown was helpful to you. In terms of what to do next, I suggest that you try different animation methods. I used lerp() for simplicity but you can replace it with any types of easing methods and you will get very different looking results. Also, you can add more variables than just x and y positions. How about color or text size animation? How about breaking up the words into letters? I hope you have some fun!

If you liked my contents, please consider supporting. Thank you!

Tezos donation address:

tz1cr64U6Ga4skQ4oWYqchFLGgT6mmkyQZe9

Kofi donation link:

Buy Me a Coffee at ko-fi.com
Copyright 2020-2021 • ErraticGenerator.com • Our Privacy Policy