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
or3000
.
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.
We also need to add constants for FORWARD
and BACKWARD
. These are used to keep track of which direction we’re moving when typing or backspacing.
import React, { useState, useEffect, useRef } from 'react';const FORWARD = 'forward';const BACKWARD = 'backward';export const useTypingText = (words,keySpeed = 1000,maxPauseAmount = 10,) => {}
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 thecurrentWord
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.
To do this, we need a useEffect
hook.
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.
export const useTypingText = (...) => {...useEffect(() => {typingInterval.current = setInterval(() => {console.log('backspace');}, keySpeed);return () => {clearInterval(typingInterval.current);}}, [currentWord, wordIndex, keySpeed, words, maxPauseAmount]);return { ... };}
Inside the useEffect
, our code should run on an interval, via setInterval
. The keySpeed
prop will determine the length of the interval.
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.
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
.
useEffect(() => {const backspace = () => {const segment = currentWord.slice(0, currentWord.length - 1);setCurrentWord(segment);letterIndex.current = currentWord.length - 1;}}, [ ... ]);
After the state update, update 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);
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 thebackspace
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.
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.
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 0let pauseCounter = 0;const typeLetter = () => {if (letterIndex.current >= words[wordIndex].length) {direction.current = BACKWARD;// Begin pause by setting the maxPauseAmount prop equal to the counterpauseCounter = 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 actionif (pauseCounter > 0) {pauseCounter = pauseCounter - 1;return;}if (direction.current === FORWARD) {typeLetter();} else {backspace();}}, keySpeed);
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 0let 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!