Build a Typewriter Effect in React
Create a classic typewriter animation that types text character by character, with support for multiple strings and a blinking cursor.
The typewriter effect never really goes out of style. It communicates something being composed in real time, which makes it feel alive in a way that static text just does not. It is one of the oldest web animations and still one of the most effective.
The final result
TextType types out a string character by character, pauses, deletes it, and moves to the next one. It supports looping through multiple strings, variable typing speed, a blinking cursor, and optional scroll-triggered start.
Setting up
The cursor blink is handled with GSAP. Everything else is vanilla React state.
import { useEffect, useRef, useState, useMemo, useCallback, createElement } from 'react';
import { gsap } from 'gsap';Building the component
The component accepts either a single string or an array of strings via the text prop.
interface TextTypeProps {
text: string | string[];
typingSpeed?: number;
deletingSpeed?: number;
pauseDuration?: number;
loop?: boolean;
showCursor?: boolean;
cursorCharacter?: string | React.ReactNode;
variableSpeed?: { min: number; max: number };
startOnVisible?: boolean;
reverseMode?: boolean;
}Normalize the input to an array immediately.
const textArray = useMemo(
() => (Array.isArray(text) ? text : [text]),
[text]
);The core animation runs in a useEffect with setTimeout. The component tracks currentCharIndex, isDeleting, and currentTextIndex in state. Each render decides what to do next based on those values.
const executeTypingAnimation = () => {
if (isDeleting) {
if (displayedText === '') {
setIsDeleting(false);
setCurrentTextIndex(prev => (prev + 1) % textArray.length);
setCurrentCharIndex(0);
} else {
timeout = setTimeout(() => {
setDisplayedText(prev => prev.slice(0, -1));
}, deletingSpeed);
}
} else {
if (currentCharIndex < processedText.length) {
timeout = setTimeout(() => {
setDisplayedText(prev => prev + processedText[currentCharIndex]);
setCurrentCharIndex(prev => prev + 1);
}, variableSpeed ? getRandomSpeed() : typingSpeed);
} else if (!loop && currentTextIndex === textArray.length - 1) {
return;
} else {
timeout = setTimeout(() => setIsDeleting(true), pauseDuration);
}
}
};The variableSpeed option randomizes typing speed per character. This makes the effect feel human rather than mechanical.
const getRandomSpeed = useCallback(() => {
if (!variableSpeed) return typingSpeed;
const { min, max } = variableSpeed;
return Math.random() * (max - min) + min;
}, [variableSpeed, typingSpeed]);The cursor is a span element animated with GSAP's yoyo repeat to create the blink.
useEffect(() => {
if (showCursor && cursorRef.current) {
gsap.to(cursorRef.current, {
opacity: 0,
duration: cursorBlinkDuration,
repeat: -1,
yoyo: true,
ease: 'power2.inOut',
});
}
}, [showCursor, cursorBlinkDuration]);How to use it
<TextType
text={['I build interfaces.', 'I write clean code.', 'I ship things.']}
typingSpeed={60}
deletingSpeed={30}
pauseDuration={2000}
loop={true}
showCursor={true}
/>Set startOnVisible to true if you want the animation to wait until the element scrolls into view rather than starting immediately.
Key takeaways
- Driving the animation with
setTimeoutinsideuseEffectgives you precise control over timing without a heavy animation library. - Cleaning up the timeout on every render (
return () => clearTimeout(timeout)) prevents stale callbacks from firing after the component unmounts. - The
variableSpeedoption is a small detail that makes a big difference in how natural the effect feels.