Creating a Typewriter Effect

2021-08-16

Creating a typewriter effect in React using useEffect in combination with setInterval, and using a generator function.

Image of an old school typewriter called 'Underwood' made in Canada
Photo by Deleece Cook

Intro

Today I want to show you how you can make a typewriter effect. A typewriter effect is an animation which renders a sentence one letter at a time, as if you are watching someone type the sentence right in front of you. The effect we are going to create will look like this:

But I want to show you my process, warts and all, from start to finish. We will start with a typewriter which is static, and make it more dynamic as we go.

Styling the typewriter

First I want to simply style the typewriter, and get the blinking cursor working.

.typewriter {
  font-family: monospace;
  font-size: 32px;
  display: inline-block;
  border-right: 10px solid black;
}

.typewriter-blink {
  animation: blink 0.75s step-start infinite;
}

@keyframes blink {
  from,
  to {
    border-color: transparent;
  }
  50% {
    border-color: black;
  }
}

The cursor itself is simply a right black border. The font is monospaced to invoke the feeling of a terminal.

The most complex thing at this moment is the animation of the cursor. By giving .typewriter-blink an animation on the border-color we simulate a blinking cursor.

The animation repeats an infinite amount of time, so it never stops blinking.

By giving it a step-start easing function it will make the animation toggle instantly between the two states, instead of gently transitioning the border-color from transparent to black, which is what normally happens. If we change the step-start to linear it will no longer feel like a cursor.

Now we just have to use the CSS classes:

import "./typewriter.css";

export default function Typewriter() {
  return (
    <div 
     className="typewriter typewriter-blink"
    >
      Hi my name is Maarten Hus
    </div>
  );
}

This results in the following blinking cursor:

Making the typewriter type

So how do we make the typewriter actually type? To solve that we must first define what typing is: typing is pressing characters, on the keyboard in sequence. The sequence of characters will form words, which form sentences.

The reason this definition matters is that it helps me get to the solution. The solution is somehow getting sequences of characters. Ideally this is what I want:

"M"
"Ma"
"Maa"
"Maar"
"Maart"
"Maarte"
"Maarten"

Each line represents a frame of the animation. You can see the sequence growing one character at a time.

My solution for this problem is using a generator function to create this sequence for me.

So what is a generator function? A generator functions is a function which can stop execution and return something using the yield keyword, and will continue from the previous yield the next time it is called.

The simplest way to use a generator to get the from above sequence is:

function* textGenerator() {
  yield "M";
  yield "Ma";
  yield "Maa";
  yield "Maar";
  yield "Maart";
  yield "Maarte";
  yield "Maarten";
}

Note the * next to the function keyword is what makes a function a generator function. A generator function returns a Generator object, this is important, it does not return the yield'ed value directly, as you might expect.

The Generator object is an Iterable which means it can be used inside of a for of statement, to easily loop over the yielded values:

const generator = textGenerator();

for (const value in generator) {
  console.log(value);
}

But nothing prevents us from looping over the values ourselves manually. The Generator object contains a next method which allows us to call the generator manually.

The next method returns an object with the following signature: {value: T, done: boolean}. Where value is what is yielded, and the done boolean signals if the generator is done.

Here is a more complex example of a generator function which returns "odd" and "even". The idea is that you can use it to generate zebra striped tables. We are going to call nextourselves instead using a for of statement.

function* zebraGenerator() {
  while (true) {
    yield 'even';
    yield 'odd';
  }
}

const zebra = zebraGenerator();

// { value: 'even', done: false}
console.log(zebra.next()); 

// { value: 'odd', done: false}
console.log(zebra.next());

// { value: 'even', done: false}
console.log(zebra.next());

// { value: 'odd', done: false}
console.log(zebra.next());

// You can call zebra.next() forever.

The cool thing here is that this generator never stops, because it uses a while(true) statement.

A basic typewriter animation

Now that we understand the basics of generator functions it is time to write our own generator for the typewriter:

export function* textGenerator(sentence: string): Generator<string> {
  let text = "";

  const sentenceAsCharArray = sentence.split("");

  for (const letter of sentenceAsCharArray) {
    text += letter;
    yield text;
  }
}

Lets dissect it: the text variable is defined as a letsignalling that we are going to mutate it. Next we take the sentence parameter, and split it up into an array of individual characters using the split method and calling it with an empty string.

This makes the sentenceAsCharArray variable: ["H", "e", "y"] if the sentence given is "Hey".

Then we simply loop over each character using a for of statement and we append each letter to the text variable, and then we yield the text. This makes each subsequent call to next get one extra letter.

We have to use the for statement here because you cannot yield from inner functions, this means that using .forEach on the array is out of the question.

By appending a new letter every yield we get the sequence we desire. Now we got to actually use the generator in the Typewriter component:

import { useEffect, useState } from "react";
import { textGenerator } from "./text-generator";
import "./typewriter.css";

export default function Typewriter() {
  const [text, setText] = useState("");

  useEffect(() => {
    const generator = textGenerator("Hi my name is Maarten Hus");

    const interval = window.setInterval(() => {
      const { value, done } = generator.next();

      if (done) {
        window.clearInterval(interval);
      } else {
        setText(value);
      }
    }, 100);

    return () => {
      window.clearInterval(interval);
    };
  }, []);

  return <div className="typewriter typewriter-blink">{text}</div>;
}

The text we are going to render is created using setState. Whenever we call setText the Typewriter component will re-render.

The trick to adding one letter at a time is using a setInterval. In the interval we call the generator to get the next text to show to the user. The interval repeats every 100 milliseconds, so a new letter is added every 100 milliseconds.

We put the setInterval inside of a useEffect with an empty array as the second parameter, so React only runs the setInterval once when the component is first rendered.

The most tricky bit is making sure the interval stops after the animation is complete. This is achieved on line 15 by calling clearInterval when done is true

Of course we also have to stop the setInterval when the animation is running, but the component is dismounted. This is done by returning a function inside of the useEffect which also calls clearInterval. When you return a function inside of theuseEffect it will act as a cleanup function.

This results in the following animation:

You need to click the refresh button to see the animation.

A better typewriter animation

Would it not be awesome if the typewriter could be given multiple sentences to write one after the other?

We need a better generator which is capable of handling multiple sentences.

The important thing is to understanding how a human types, and specifically how a human alters text. For example when you have the text Mark and you want to change it to Maarten you remove the rk and start typing arten. You would not remove Mark entirely.

In other words we need to know how many backspaces are between each sentence, to get a realistic backspaces:

export function calculateBackspaces(from: string, to: string): number {
  let charsInCommonFromStart = 0;

  for (let i = 0; i < from.length; i++) {
    const fromChar = from[i];
    const toChar = to[i];

    if (toChar === fromChar) {
      charsInCommonFromStart += 1;
    } else {
      break;
    }
  }

  return from.length - charsInCommonFromStart;
}

The calculation is relatively simple: the number of backspaces needed is the length of the current text minus the characters both sentences have in common from the start.

There are two scenarios: either the entire sentence needs to be removed before starting the new sentence, or part of the sentence needs to be removed. This depends on whether or not the two sentences have overlapping characters at the start of the sentences.

The first scenario: nothing in common. If the from is "Salt" and the to is "Pepper". The number of letters in common would be 0. Then the number of backspaces would be: "Salt".length - 0 which is 4. Which makes sense because the entirety of "Salt" needs to be removed, it has nothing in common with "Pepper".

The second scenario: characters in common. If the from is: "Mark" and a to is "Maarten". The amount of letters they have have in common from the start is 2 being "Ma". So the number of backspaces we need to do is: "Mark".length - 2 which is 2. This is correct because we want to start typing from "Ma" and add "arten".

Now that we have a way of calculating backspaces between sentences we can alter the generator:

import { calculateBackspaces } from "./calculate-backspaces";

export function* textGenerator(sentences: string[]): Generator<string> {
  let text = "";

  for (const sentence of sentences) {
    const backspaces = calculateBackspaces(text, sentence);

    // Now apply the number of backspaces
    for (let i = 0; i < backspaces; i++) {
      text = text.slice(0, -1);

      yield text;
    }

    // The tricky bit
    const missingChars = sentence.slice(text.length);

    const missingCharsArray = missingChars.split("");

    for (const missingChar of missingCharsArray) {
      text += missingChar;

      yield text;
    }

    // Add a delay between sentences
    const delay = 15;
    for (let i = 0; i < delay; i++) {
      yield text;
    }
  }
}

We loop through each sentence, and start by applying the amount of backspaces needed. On the first iteration there will be no backspaces because the text is empty.

Then comes the tricky bit on line 16: Now we must calculate which characters are missing in the current text. Say the old sentence is "Mark" and the new sentence is "Maarten". The text variable is now "Ma" at this point, because we have already hit backspace at this point.

The goal is to end up with a string with value"arten" so we can append that to the text.

By calling sentence.slice(text.length); we set missingChars to "arten". slice will drop the amount of letters from the start of the string based on the first argument. So when we call "Maarten".slice(2) we end up with "arten".

Finally we need some sort of delay in between the sentences, because otherwise they would very quickly disappear. That is what the delay on line 28 does, it is the number of iterations the text is simply yielded as is. Why 15 because that feels right to me.

Then in the Typewriter component we now call the generator like this in the useEffect:

const generator = textGenerator([
  "Hi my name is MrHus",
  "Hi my name is Maarten Hus"
]);

Which results in a typewriter effect which switches between sentences, using realistic backspaces:

You need to click the refresh button to see the animation.

The cursor is broken

If you pay close attention to the animation you will see that the cursor is a little off. The reason why is that the cursor keeps blinking even when the "user" is typing. This causes the cursor to jump erratically.

What we want to do is stop the animation during typing and only start blinking after the animation is finished.

What we are going to do is return from the generator whether or not the cursor should be blinking at this point:

import { calculateBackspaces } from "./calculate-backspaces";

export type TextGeneratorResult = {
  text: string;
  blink: boolean;
};

export function* textGenerator(
  sentences: string[]
): Generator<TextGeneratorResult> {
  let text = "";

  for (const sentence of sentences) {
    const backspaces = calculateBackspaces(text, sentence);

    // Now apply the number of backspaces
    for (let i = 0; i < backspaces; i++) {
      text = text.slice(0, -1);

      // Do not blink when typing
      yield { text, blink: false };
    }

    // The tricky bit
    const missingChars = sentence.slice(text.length);

    const missingCharsArray = missingChars.split("");

    for (const missingChar of missingCharsArray) {
      text += missingChar;

      // Do not blink when typing
      yield { text, blink: false };
    }

    // Add a delay between sentences
    const delay = 15;
    for (let i = 0; i < delay; i++) {
      // Blink when not typing
      yield { text, blink: true };
    }
  }
}

As you can see we now no longer return a string but instead we return a TextGeneratorResult which contains the text to display and whether or not the cursor should blink

Then it is just a matter of returning the correct blink. Which isfalse whenever the "user" is typing, and true when the typing stops.

Now we have to alter the Typewriter component so it handles the TextGeneratorResult.

import { useEffect, useState } from "react";
import { textGenerator, TextGeneratorResult } from "./text-generator";
import "./typewriter.css";

export default function Typewriter() {
  const [{ text, blink }, setResult] = useState<TextGeneratorResult>(() => ({
    text: "",
    blink: false
  }));

  useEffect(() => {
    const generator = textGenerator([
      "Hi my name is MrHus",
      "Hi my name is Maarten Hus"
    ]);

    const interval = window.setInterval(() => {
      const { value, done } = generator.next();

      if (done) {
        window.clearInterval(interval);
      } else {
        setResult(value);
      }
    }, 100);

    return () => {
      window.clearInterval(interval);
    };
  }, []);

  const className = `typewriter ${blink ? 'typewriter-blink' : ''}`;

  return <div className={className}>{text}</div>;
}

As you can see the setState is of the type TextGeneratorResult now. It stores the result of the generators current value, and uses that to display the text and control the blinking.

On line 32 we dynamically set the className based on the blink variable. This triggers the CSS animation when by giving it the .typewriter-blink class

Now we have the complete typewriter we saw at the beginning of the post.

You need to click the refresh button to see the animation.

Conclusion

I hope this post demonstrated the power of generators. Generators offer a unique way to control the flow of a program.

A cool use of generators in the wild is a library called Redux-Saga it allows you to manger a Redux store using generators.



Related courses

Image of a tree representing the fact that all React applications are trees

Hands-on React

Learn React in this multi-day course, at the end of the course you can build React applications on your own using: components, hooks and TypeScript.

Read more
Image of the Bromo volcano on Java island it represents the origin of the name JavaScript

Mastering JavaScript

Master JavaScript in this multi-day course, after which you will have mastered: promises, proxies, generators, modules and more.

Read more