photo of backlit keyboard
Photo by Sam Albury on Unsplash

A Typing Text Effect with React Hooks

Build a hook that backspaces and types out an array of words.

9 min read

In this post, we’ll use Javascript to type out a word and then backspace it before moving to the next word in the set.

Some examples of this effect out in the wild can be seen at Payprus and the Spotify jobs page, but there are certainly a lot more out there!

To make this effect flexible and reusable, we'll write it as a custom React hook.

Getting Started

First, let’s define what we need to do.

At a high level, our hook needs to type out a word, backspace it, move to the next and do the same.

For this, we should have an array of words to cycle through. Something like ['fast', 'reliable', 'affordable']. This param will be named words.

We also want to be able to control the speed at which the text is typed. This should be dictated by the context in which the hook is being used. For example, a bold, dramatic-looking website might require the text to be typed slower.

We can control this by passing a number in milliseconds. That will be keySpeed.

To better visualize what we’re working on in this article, set the keySpeed param to a value like 2000 or 3000.

Finally, we should be able to control the delay from when a word is typed until it’s backspaced. This ensures the word can actually be read before being removed.

This is handled by the maxPauseAmount param. Instead of an exact millisecond number, it’s a number to count down from based on the speed value.

The way the hook will be used is demonstrated below. It returns a word HTML element to be added into a paragraph or heading.


const { word } = useTypingText(['fast', 'reliable', 'affordable'], 130, 20);

return (
  <h1>Our product is {word}</h1>
)

In general, this hook is best used for single words in a heading, but it could be used for multiple words, or even sentences. Experiment and see what works for your project.

Getting Setting Up

Let’s start writing some code by making a new file named useTypingText.js. This is also the name of our hook.

Right away we’ll include the words, keySpeed and maxPauseAmount params covered in the last section. You can add some defaults for these params if you’d like. A default keySpeed of 1000 and maxPauseAmount of 10 would work just fine.


import React, { useState, useEffect, useRef } from 'react';

const FORWARD = 'forward';
const BACKWARD = 'backward';

export const useTypingText = (
  words,
  keySpeed = 1000,
  maxPauseAmount = 10,
) => {}

We also need to add constants for FORWARD and BACKWARD. These are used to track which direction we’re moving when typing or backspacing.

At this point, be sure to import useState, useEffect and useRef. We will also need React to be in scope because our hook is returning JSX. This is because of the react-in-jsx-scope ESLint rule.

Next, there are two state values to define.

The first wordIndex will track an index used to determine which word should be displayed.

The second state value, currentWord, is derived from the previous value. currentWord is an array of letters.


export const useTypingText = (
  words,
  keySpeed = 1000,
  maxPauseAmount = 10,
) => {
  const [wordIndex, setWordIndex] = useState(0);
  const [currentWord, setCurrentWord] = useState(words[wordIndex].split(''));
}

There are also three ref values to add.


export const useTypingText = (
  words,
  keySpeed = 1000,
  maxPauseAmount = 10,
) => {
  const [wordIndex, setWordIndex] = useState(0);
  const [currentWord, setCurrentWord] = useState(words[wordIndex].split(''));

	const direction = useRef(BACKWARD);
	const typingInterval = useRef();
	const letterIndex = useRef();
}

The first is direction and it'll use the BACKWARD and FORWARD constants we added.

The second is typingInterval, which is a reference to a setInterval. We're storing this in a ref so it can be cleared via clearInterval.

The last one is letterIndex, which stores the index of the letter in the current word being typed or backspaced.

Last, we have the return statement.


export const useTypingText = (...) => {
  ...
	return {
	  word: (
	    <span className={`word ${currentWord.length ? 'full' : 'empty'}`}>
	      <span>{currentWord.length ? currentWord.join('') : '0'}</span>
	    </span>
	  ),
	};
}

word will contain two nested span elements, and we set CSS classes and the text value depending on the length of the currentWord state. This is because we need to treat the word differently if it’s 0 characters long as opposed to containing characters.

Notice how we’re rendering a string of 0 if we have no characters left in the currentWord state. This is to make sure the span has some kind of content so it maintains a height and doesn't collapse. The content itself isn’t important because we’ll be hiding it with CSS later, so it could realistically be anything.

Backspacing the First Word

Now that we have the basics done, let’s move on to backspacing a word.

As we progress, you’ll notice that the logic is contained in a single useEffect. This could be broken down further depending on your preferences, but for this tutorial, we’ll leave it as-is.

To do this, we need a useEffect hook.

Inside the useEffect, our code should run on an interval, via setInterval. The keySpeed prop will determine the length of the interval.


export const useTypingText = (...) => {
	...
	useEffect(() => {
    typingInterval.current = setInterval(() => {
      console.log('backspace');
    }, keySpeed);

    return () => {
      clearInterval(typingInterval.current);
    }
  }, [currentWord, wordIndex, keySpeed, words, maxPauseAmount]);

	return { ... };
}

Be sure to return a function from the useEffect to clear the interval in the event a component using this hook is unmounted.

If you pause here and view your progress in the browser, you should see some output in the console.

Next, add a new function named backspace inside of the useEffect. It will contain all the logic for handling backspacing.


useEffect(() => {
	const backspace = () => {
	  const segment = currentWord.slice(0, currentWord.length - 1);
	  setCurrentWord(segment);
	  letterIndex.current = currentWord.length - 1;
	}
}, [ ... ]);

In the function, we should determine what the next part of the word is after a single backspace. For example, if the word is “awesome”, the currentWord state should be set to “awesom”, and the next time it runs, “aweso”, and so on.

This is done by taking the currentWord state value and calling slice() on it to remove the last letter and then set it to state using setCurrentWord.

After the state update, set the value of the letterIndex ref to reflect the length of the current word. We’ll reference this in the next section.

Lastly, call backspace from inside the setInterval.


typingInterval.current = setInterval(() => {
  backspace();
}, keySpeed);

backspacing the word
backspacing the word

Typing the Word

Now that we can backspace a word, the next step is to figure out when we’ve hit the “end” of that word, switch to the next one, and type it.

Let’s revisit the backspace function we just wrote.

First check if the letterIndex ref value is 0. If it is, we update the wordIndex state to either the next word (wordIndex + 1), or the first one (0), depending on if we’ve reached the end of the words array. Then we set the direction ref value to FORWARD to indicate we need to type the next word.


const backspace = () => {
  if (letterIndex.current === 0) {
    const isOnLastWord = wordIndex === words.length - 1;

    setWordIndex(!isOnLastWord ? wordIndex + 1 : 0);
    direction.current = FORWARD;

    return;
  }

  const segment = currentWord.slice(0, currentWord.length - 1);
  setCurrentWord(segment);
  letterIndex.current = currentWord.length - 1;
}

The return statement above is important. Since we’ve backspaced the word down to 0 characters, and now want to start typing the next one, the rest of the backspace function should not run.

Next, add a new function named typeLetter. This is where the typing is done.


const typeLetter = () => {
  if (letterIndex.current >= words[wordIndex].length) {
    direction.current = BACKWARD;
    return;
  }
}

Similar to what we just did, the typeLetter function should define when to switch the direction to BACKWARD again. We’ll again use the letterIndex ref to determine when this should happen.

Now to actually type the word.


const typeLetter = () => {
  if (letterIndex.current >= words[wordIndex].length) {
    direction.current = BACKWARD;
    return;
  }

  const segment = words[wordIndex].split('');
  setCurrentWord(currentWord.concat(segment[letterIndex.current]));
  letterIndex.current = letterIndex.current + 1;
}

First, break the current word string into an array using split().

Then update the currentWord state, using both letterIndex and currentWord to derive what the new state value should be.

Just like in the backspace function, we need to update the value of letterIndex, incrementing it by 1 this time.

Now that we’re fully utilizing BACKWARD and FORWARD, the interval function should be updated to check whether to call backspace or typeLetter.


typingInterval.current = setInterval(() => {
  if (direction.current === FORWARD) {
    typeLetter();
  } else {
    backspace();
  }
}, keySpeed);

Now we should have a constant backspace and typing sequence in place.

backspacing and then typing a word
backspacing and then typing a word


Blinking Cursor and Styles

The styles that go along with our work are pretty minimal.


.word {
  display: block;
}

.word span {
  color: #ff5252;
  position: relative;
}

.word span::after {
  content: '';
  width: 8px;
  height: 100%;
  background: #ff5252;
  display: block;
  position: absolute;
  right: -10px;
  top: 0;
  animation: blink 0.5s ease infinite alternate-reverse;
}

@keyframes blink {
  from {
    opacity: 100%;
  }

  to {
    opacity: 0%;
  }
}

.word.empty {
  visibility: hidden;
}

.word.empty span::after {
  visibility: visible;
  right: 0;
}

The hook file doesn’t load any CSS, so be sure to import these styles in a component using the hook, or globally in your project.

cycling through words backspacing then typing
cycling through words, backspacing then typing


Pausing Between Words

We’ve made some great progress so far, but we’re not quite done! You may have noticed that the transition between typing and backspacing feels a bit unnatural and the word is hard to read because there’s no pause between the two states. Let’s change that.

At the top of the main useEffect , use let to add a pauseCounter. We’ll use this in combination with the maxPauseAmount parameter to set a counter when we hit the end of the word while typing.


useEffect(() => {
  // Start at 0
  let pauseCounter = 0;

	const typeLetter = () => {
    if (letterIndex.current >= words[wordIndex].length) {
      direction.current = BACKWARD;

      // Begin pause by setting the maxPauseAmount prop equal to the counter
      pauseCounter = maxPauseAmount;
      return;
    }

    const segment = words[wordIndex].split('');
    setCurrentWord(currentWord.concat(segment[letterIndex.current]));
    letterIndex.current = letterIndex.current + 1;
  }
}, [ ... ]);

In the typeLetter function, set the pauseCounter equal to the maxPauseAmount when we hit the end of the word.

Back in the setInterval call, add a check to see if the pauseCounter value is greater than 0. If it is, decrement it by 1, and return. This will keep us in a paused state until the counter hits 0 again.


typingInterval.current = setInterval(() => {
  // Wait until counter hits 0 to do any further action
  if (pauseCounter > 0) {
    pauseCounter = pauseCounter - 1;
    return;
  }

  if (direction.current === FORWARD) {
    typeLetter();
  } else {
    backspace();
  }
}, keySpeed);

typing effect with a pause between words
typing effect with a pause between words

Stopping and Starting

In this section, we’ll cover how to stop and start the typing effect. This can be useful if, for example, you only want this effect to run when the words are visible on the user’s screen.

First, we need to add a new isStopped state value into the hook. This determines if we’re in a stopped state or not.


export const useTypingText = (...) => {
  ...
  const [isStopped, setIsStopped] = useState(false);
}

Next add a function named stop. It will do exactly what the name suggests: set the isStopped state value to true, and clear the typingInterval.


export const useTypingText = (...) => {
	...
  const [isStopped, setIsStopped] = useState(false);

  const stop = () => {
    clearInterval(typingInterval.current);
    setIsStopped(true);
  }
}

At the top of the useEffect, be sure that none of the functionality runs when we’re in a stopped state. We can do that just by checking if isStopped is true, and returning.


useEffect(() => {
  // Start with 0
  let pauseCounter = 0;

  if (isStopped) return;

  return () => {
    clearInterval(typingInterval.current);
  }
}, [currentWord, wordIndex, keySpeed, words, isStopped, maxPauseAmount]);

Don’t forget to add isStopped into the dependency array.

Finally let’s revisit the return statement to add the stop function as a method that can be called outside of the hook.


return {
  text: (
    <span className={`word ${currentWord.length ? 'full' : 'empty'}`}>
      <span>{currentWord.length ? currentWord.join('') : '0'}</span>
    </span>
  ),
  start: () => setIsStopped(false),
  stop,
};

Once in a stopped state, the functionality can be started again by setting isStopped back to false. The start method will do that.

Now the typing can be stopped and started.


const { text, stop, start } = useTypingText(
  ['fast', 'reliable', 'affordable'],
  130,
  20,
);

return (
  <div className="container">
    <h1>Our product is {text}</h1>
    <button onClick={stop}>Stop</button>
    <button onClick={start}>Start</button>
  </div>
);

Summary

In this article, you learned how to build a custom React hook that types out a word and backspaces it. This can be a nice effect to break up an otherwise flat, static page. It works especially nicely for headings with large font sizes.

Experiment with the speed and pause parameters to see what best matches your project!