apocalypse

dev

Writing your own typewriter effect in React

Typewriter animation is a great way to add some cool effect to your page, especially the hero section. In this post I'm gonna show you how to do just that.

So how to do this? As with everything in JS, we have essentially two options: build this yourself or use a library. Anything you can think of? There's probably an npm package that does just that. That's the rule 34 of javascript libraries.

I have found two great libraries for react that you can use to implement this effect if you're short on time:

  1. react-type-animation
  2. react-typed

With the benefit of quick start, there are also a few downsides coming with this approach:

  • libraries are usually more powerful then you really need (that means possibly more code in the bundle to do something rather simple)
  • libraries are those black boxes that do some stuff, giving you some nice interface to define what stuff exactly do you need, but you don't really know how this effect was achieved (unless you go through their github repo, which, let's be honest, we rarely do)

In this blog I want to cultivate the curious mindset and show you how to do such things yourself. Because of that, I'm not gonna copy-paste the documentation of those libraries here - if you're interested in using one of these libraries, you can just go to the linked pages and look up some examples ;).

So without further ado, let's get to work. We need a component. It should take text as a prop. We could also add a className prop, and pass it to the span element, so that we could extend its default styles. In my use case though it won't be needed, needless to say the span element could be placed inside some other element and inherit its styles.

interface TypeWriterProps { text: string; } const TypeWriter = ({ text }: TypeWriterProps) => { return <span>{text}</span>; }; export default TypeWriter;

That's our starting point. I'm using span element in here, because I want this animation to be displayed in one line. For purposes of this tutorial I'm gonna keep this fairly simple. You can still embed <span> element in <h1> element, for example, to apply this animation to your hero section text. That's exactly what we're gonna do later in this post.

Now let's make a state that would keep track of what's currently displayed. We also need to keep track of index of a currently displayed character, so that would be a second state in this component.

const [displayedText, setDisplayedText] = useState(''); const [currentIndex, setIndex] = useState(0);

Now let's move to the actual animation. Since this animation is based on the idea, that every few milliseconds we are adding new letter to the output text, we could use setInterval. But that way we would have to clear this interval in a proper moment and be more wary of how to act when this component would be unmounted. I think setTimeout would be of a better use here - we can wrap it with useEffect and run it as long as the currentIndex of text is within the range of the actual text.

useEffect(() => { if (currentIndex < text.length) { const timeout = setTimeout(() => { setDisplayedText((prevText) => prevText + text[currentIndex]); setIndex((prevIndex) => prevIndex + 1); }, 100); return () => clearTimeout(timeout); } }, [currentIndex]);

In the timeout we are updating displayedText, adding another letter to it, as well as incrementing current index by 1. I have set the timeout value to 100ms, but we could easily make this a prop, like speed, and pass it down to the component. We can also set some default value to avoid errors when user don't provide it.

interface TypeWriterProps { text: string; speed?: number; } const TypeWriter = ({ text, speed = 75 }: TypeWriterProps) => { ... const timeout = setTimeout(() => {...}, speed); ... }

We could also play with this timeout, maybe decrease it with each letter, to make this effect run faster till the end (just make sure to stay within the positive numbers) speed - (displayedText.length) * 3

We could make it more human-like by adding or decreasing some random value from it speed + Math.random() * 5

We can get creative with it. Let's see the end result for our TypeWriter v1:

'use client'; // add this if you're using nextjs v13 import { useEffect, useState } from 'react'; interface TypeWriterProps { text: string; speed?: number; } const TypeWriter = ({ text, speed = 75 }: TypeWriterProps) => { const [displayedText, setDisplayedText] = useState(''); const [currentIndex, setIndex] = useState(0); useEffect(() => { if (currentIndex < text.length) { const timeout = setTimeout(() => { setDisplayedText((prevText) => prevText + text[currentIndex]); setIndex((prevIndex) => prevIndex + 1); }, speed); return () => clearTimeout(timeout); } }, [currentIndex]); return <span>{displayedText}</span>; }; export default TypeWriter;

We can use this component inside some other component, and our typewriter's span would inherit its styles.

<h1 className='styles for the h1'> <TypeWriter text='apocalypse.dev' speed={120} /> </h1>

First versionFirst version

We can see some issue here though - our layout jumps because we are not displaying anything on the beginning. That's something that can be fixed easily - we need to add height property to our element. The height should be exactly 1em (not rem!). You can set it in classic CSS like height: 1em or in tailwind adding class h-[1em].

First version with added heightFirst version with added height

That's better.

But there's one more thing I would like to add, and it's the cursor. Every typing animation should have a cursor to make this really authentic. I guess there are many ways of adding this element, but first thing that comes to my mind, that would not involve creating another HTML element, would be the use of ::after pseudoelement.

Typewriter animation with cursorTypewriter animation with cursor

That's the effect we're going for. We can achieve this using just CSS, so here's the code snippet for that:

/* our span should be assign this className */ .typewriter::after { content: ''; position: absolute; right: -0.5em; // play around with it bottom: 0.25em; // play around with it border-bottom: 4px solid white; width: 0.5em; // play around with it animation: blink 1s ease-in-out infinite; } @keyframes blink { 0%, 100% { opacity: 0; } /* it's 20%, not 50%, to make this less robotic, more realistic; you can obviously play around with that and find the value that feels right to you */ 20%: { opacity: 1; } }

I have set the width, right and bottom properties in em, not rem or px, just so that it's more universal and can be applied to text of any size. Bear in mind that these values are dependant on the font that you're using, so you probably still need to fine-tune them to your needs.

Now, it's just the raw CSS, but it's no biggie to translate it to Tailwind. Adding keyframes to tailwind config is quite easy:

const config: Config = { ... theme: { extend: { keyframes: { blink: { '0%, 100%': { opacity: '0' }, '20%': { opacity: '1' }, }, }, animation: { blink: 'blink 1s ease-in-out infinite', }, } } }

Now you can use className of animate-blink to automatically apply this animation. The rest of the styles, however, I moved to the global css file, because writing it inline seemed like a bit of a hustle:

after:content-[""] after:absolute after:right-[-2.5rem] after:bottom-4 after:w-10 after:border-b-4 after:border-b-[white] after:border-solid after:animate-blink

The final code in tailwind looks like that:

<span className='relative typewriter after:animate-blink'>{displayedText}</span>

There are a few more improvements with the animation, though. Ideally, we would want to show the cursor only after the first letter is being type, to avoid it floating around before the browser process the JS code. Also, we may want to hide the cursor after a few seconds, so that it doesn't distract our users from the rest of the page. Needless to say, it may not be ideal for the page performance to run some animation in the foreground the entire time. With these new requirements we may add one more state to the application, and one more prop, to make this work.

interface TypeWriterProps { text: string; speed?: number; // add delay as a prop cursorHideDelay?: number; } const TypeWriter = ({ text, speed = 75, cursorHideDelay = 2500 }: TypeWriterProps) => { const [displayedText, setDisplayedText] = useState(''); const [currentIndex, setIndex] = useState(0); // state for our cursor const [shouldDisplayCursor, setDisplayCursor] = useState(false); useEffect(() => { if (currentIndex < text.length) { const timeout = setTimeout(() => { setDisplayedText((prevText) => prevText + text[currentIndex]); setIndex((prevIndex) => prevIndex + 1); if (!shouldDisplayCursor) { // set this to true only after the animation starts setDisplayCursor(true); } }, speed - displayedText.length * 3); return () => clearTimeout(timeout); } else { // this is a moment where the animation is finished - the full text is written const endAnimationTimeout = setTimeout(() => { // after the cursorHideDelay interval we want to hide the cursor setDisplayCursor(false); }, cursorHideDelay); // cleanup in case somebody leaves the page before timeout gets executed return () => clearTimeout(endAnimationTimeout); } }, [currentIndex]); return ( <span // we set this className conditionally, when the cursor should be displayed className={shouldDisplayCursor ? `relative typewriter after:animate-blink` : ''} > {displayedText} </span> ); };

Final resultFinal result

That's our end result. There are still some improvements that could be done. For example we may not want to animate the cursor while the text is being typed - that's not how cursors work. I think adding this functionality to the code wouldn't be much work, but I'm gonna stop it there, to avoid dragging this tutorial forever.

I hope you get the gist of how to make such animation work using React.

Related tags:

react
typewriter
animation
tutorial