Mastering Typewriter Text Animation in React: From CSS to Production‑Ready Hooks
Build smooth, accessible typewriter animations in React—learn CSS-only, a reusable hook, deletion/looping, SSR tips, and performance best practices.
Image used for representation purposes only.
Overview
Typewriter text animation is a small flourish that can make product headlines, onboarding steps, or command‑palette hints feel alive. In React, though, it’s easy to stumble into jank, hydration mismatches, and accessibility issues. This guide walks you from a minimal implementation to a production‑ready, reusable hook—covering deletion/looping, grapheme‑safe typing (for emoji and complex scripts), SSR/Next.js considerations, and testing.
When to use a typewriter effect
- Draw attention to a single, changing word or short phrase in a headline (e.g., Build → Ship → Delight).
- Simulate a command‑line or chat bot prompt.
- Tease features or categories in hero sections.
Avoid it for long paragraphs or essential content. Motion should enhance—not block—comprehension. Always provide a readable, stable fallback and obey reduced‑motion preferences.
A minimal React component
Here’s a compact component that types a single string once. It’s a good starting point to understand the timing model.
import { useEffect, useState } from 'react';
type Props = {
text: string;
speed?: number; // ms per character
startDelay?: number; // ms before typing starts
onDone?: () => void;
};
export function Typewriter({ text, speed = 50, startDelay = 0, onDone }: Props) {
const [output, setOutput] = useState('');
useEffect(() => {
let i = 0;
let cancelled = false;
const start = setTimeout(() => {
const tick = () => {
if (cancelled) return;
setOutput(prev => prev + text[i]);
i += 1;
if (i < text.length) {
setTimeout(tick, speed + Math.random() * speed * 0.2); // subtle humanization
} else {
onDone?.();
}
};
tick();
}, startDelay);
return () => {
cancelled = true;
clearTimeout(start);
};
}, [text, speed, startDelay, onDone]);
return (
<span aria-live='polite'>
{output}
<span className='caret' aria-hidden='true'>|</span>
</span>
);
}
Add a blinking caret and reduced‑motion support:
.caret { display: inline-block; width: 1ch; animation: blink 1s steps(1, end) infinite; }
@keyframes blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .caret { animation: none; } }
Limitations of the minimal version:
- Types only one string, no deletion or looping.
- Not grapheme‑safe (may split emoji or combined characters).
- Timing control is basic.
A reusable hook: useTypewriter
Let’s build a flexible hook that cycles through a list of words, supports deletion and pauses, and respects reduced motion. We’ll also handle grapheme segmentation so we don’t slice emoji or complex clusters in the middle.
import { useEffect, useMemo, useRef, useState } from 'react';
type Phase = 'typing' | 'pausing' | 'deleting';
type Options = {
words: string[];
loop?: boolean;
typingSpeed?: number; // ms per char
deletingSpeed?: number; // ms per char when deleting
pauseBetween?: number; // ms pause after a word completes
startDelay?: number;
locale?: string; // for Intl.Segmenter
disabled?: boolean; // honor reduced motion
};
function splitGraphemes(str: string, locale = 'en') {
try {
// @ts-ignore: Segmenter exists in modern browsers
const seg = new Intl.Segmenter(locale, { granularity: 'grapheme' });
return Array.from(seg.segment(str), s => s.segment);
} catch {
// Fallback: not perfect for all emoji, but reasonable baseline
return Array.from(str);
}
}
export function useTypewriter({
words,
loop = true,
typingSpeed = 60,
deletingSpeed = 30,
pauseBetween = 1000,
startDelay = 0,
locale = 'en',
disabled = false,
}: Options) {
const [index, setIndex] = useState(0); // word index
const [phase, setPhase] = useState<Phase>('typing');
const [output, setOutput] = useState('');
const started = useRef(false); // avoid double-start in React 18 StrictMode (dev)
const timeoutRef = useRef<number | null>(null);
const graphemes = useMemo(() => splitGraphemes(words[index] ?? '', locale), [words, index, locale]);
const clear = () => { if (timeoutRef.current) window.clearTimeout(timeoutRef.current); };
useEffect(() => {
if (disabled || words.length === 0) return;
if (!started.current) {
started.current = true;
if (startDelay > 0) {
timeoutRef.current = window.setTimeout(() => setPhase('typing'), startDelay);
return () => clear();
}
}
if (phase === 'typing') {
if (output.length < graphemes.length) {
const next = graphemes.slice(0, output.length + 1).join('');
const jitter = Math.random() * typingSpeed * 0.2;
timeoutRef.current = window.setTimeout(() => setOutput(next), typingSpeed + jitter);
} else {
setPhase('pausing');
}
} else if (phase === 'pausing') {
timeoutRef.current = window.setTimeout(() => setPhase('deleting'), pauseBetween);
} else if (phase === 'deleting') {
if (output.length > 0) {
const next = graphemes.slice(0, output.length - 1).join('');
timeoutRef.current = window.setTimeout(() => setOutput(next), deletingSpeed);
} else {
const nextIndex = index + 1;
if (nextIndex < words.length) setIndex(nextIndex);
else if (loop) setIndex(0);
setPhase('typing');
}
}
return () => clear();
}, [phase, output, graphemes, typingSpeed, deletingSpeed, pauseBetween, startDelay, index, words.length, loop, disabled]);
useEffect(() => { setOutput(''); }, [index]);
return { text: output, phase };
}
Usage:
function Headline() {
const prefersReducedMotion = typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
const { text, phase } = useTypewriter({
words: ['Build', 'Ship', 'Delight'],
typingSpeed: 55,
deletingSpeed: 28,
pauseBetween: 900,
locale: 'en',
disabled: prefersReducedMotion,
});
return (
<h1 className='hero'>
We <span aria-live='polite'>{text}</span>
<span className={`caret ${phase !== 'typing' ? 'dim' : ''}`} aria-hidden='true'>|</span>
</h1>
);
}
Optional caret polish:
.caret { display: inline-block; width: 0.6ch; animation: blink 1s steps(1) infinite; }
.caret.dim { opacity: 0.4; }
@keyframes blink { 50% { opacity: 0; } }
Accessibility essentials
- Live regions: Wrap the dynamic text in
aria-live='polite'so screen readers announce changes without interrupting the user. - Reduced motion: Honor
prefers-reduced-motion. You can disable the hook (show the final word), or switch to an instant crossfade. - Semantics: Avoid using the animation to convey information that isn’t available statically. Consider rendering a visually hidden static sentence with
.sr-onlyfor users who prefer no motion.
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
CSS‑only approach (steps animation)
For one‑off, single‑line words and known lengths, a CSS trick is zero‑JS, GPU‑friendly, and crisp.
<h1 class='tw' aria-hidden='true'>Hello, world!</h1>
<span class='sr-only' aria-live='polite'>Hello, world!</span>
.tw {
font: 600 2.5rem/1 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
white-space: nowrap; overflow: hidden; border-right: .08em solid currentColor;
width: 13ch; /* must match character count */
animation: typing 2.6s steps(13, end), blink 1s step-end infinite;
}
@keyframes typing { from { width: 0 } to { width: 13ch } }
@keyframes blink { 50% { border-color: transparent } }
Caveats:
- You must know the exact character count (works best with monospace fonts and basic Latin characters).
- Not suitable for dynamic lists, deletion, or responsive content.
- Hide it from assistive tech and provide a static equivalent as shown.
Using a library (e.g., Typed.js) with React
If you prefer batteries‑included features (looping, backspacing, shuffling), a small library is fine. Here is a safe pattern that plays well with React and SSR.
import { useEffect, useRef } from 'react';
export function TypedHeadline() {
const elRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
let cleanup = () => {};
// Dynamic import so SSR environments don’t execute browser code
import('typed.js')
.then(({ default: Typed }) => {
if (!elRef.current) return;
const typed = new Typed(elRef.current, {
strings: ['Build', 'Ship', 'Delight'],
typeSpeed: 55,
backSpeed: 28,
backDelay: 900,
loop: true,
});
cleanup = () => typed.destroy();
})
.catch(() => {});
return () => cleanup();
}, []);
return <h1>We <span ref={elRef} aria-live='polite' /></h1>;
}
In Next.js, you can also lazy‑load the component on the client only:
// pages/index.tsx
import dynamic from 'next/dynamic';
const TypedHeadline = dynamic(() => import('../components/TypedHeadline'), { ssr: false });
export default function Page() {
return <TypedHeadline />;
}
Server‑side rendering and hydration
- Start states: On the server, render a stable snapshot (e.g., the first word or the full final word). Begin animation after mount to avoid hydration mismatches.
- Suppress warnings: If the client text will differ initially, wrap the dynamic portion with
suppressHydrationWarningand replace its contents on mount. - Lazy/dynamic import: For libraries that touch
windowduring import, use dynamic imports orssr: falsein Next.js.
<span suppressHydrationWarning>{/* replaced on mount */}</span>
Performance and React 18 details
- Cleanup all timers: Keep timeout IDs in refs and clear them on unmount to prevent memory leaks.
- Dev double‑invocation: In React 18 Strict Mode (development only), effects may run twice. Use guards (like
started.current) and robust cleanup. - Avoid re‑renders: Derive next characters inside effects and limit state to the minimum (
text,phase). Style caret with CSS classes instead of state.
Internationalization and graphemes
Simple str[i] indexing can split emojis (👨👩👧👦), flags (🇺🇳), or accented clusters. Prefer Intl.Segmenter where available, with a library fallback such as a grapheme splitter. Our hook demonstrates a best‑effort approach that works well in modern browsers.
Testing the effect
Use Jest or Vitest with fake timers to deterministically advance time.
import { render, screen } from '@testing-library/react';
import { Typewriter } from './Typewriter';
jest.useFakeTimers();
test('types hello', () => {
render(<Typewriter text='Hello' speed={10} />);
jest.advanceTimersByTime(50); // ~5 chars at 10ms each
expect(screen.getByText(/Hello/)).toBeInTheDocument();
});
For hook tests, render a host component that displays the text and assert transitions (typing → pausing → deleting) by advancing timers.
Polishing ideas
- Humanization: Add a ± jitter to speeds, occasional micro‑pauses on punctuation.
- Cursor variants: Block (▉), underscore (_), or themed colors; pause cursor blink while typing.
- Content strategy: Limit to short words or phrases; keep loop intervals predictable.
- Event hooks: Fire
onWordStart/onWordEndfor analytics or to sync other UI.
Production checklist
- Respects prefers‑reduced‑motion (disables or switches to instant change).
- Uses
aria-live='polite'and provides a static text alternative. - Grapheme‑safe segmentation for emoji/RTL scripts.
- No hydration mismatches (stable server snapshot; client animates after mount).
- Timers cleaned up; no leaks; handles React 18 Strict Mode.
- Tested with fake timers for predictable behavior.
Conclusion
A typewriter effect should be delightful and invisible—never the reason a headline jitters, hydration fails, or a screen reader babbles. With a small, well‑factored hook, grapheme‑aware segmentation, accessible fallbacks, and attention to SSR, you can ship a smooth, production‑ready animation that supports every user and every locale.
Related Posts
Building an Accessible, Animated Expandable Card in React
Build an accessible, animated React expandable card with TypeScript. Covers ARIA, controlled/uncontrolled state, CSS and Framer Motion, and testing.
Build a Polished React Animated Counter in React (Hook + Component, A11y-First)
Build an accessible React animated counter with a reusable hook and component—easing, formatting, reduced motion, viewport start, and full control.
Building a Robust React Aspect Ratio Container in React (with Fallbacks)
Build a robust React aspect-ratio container with CSS and fallbacks. Includes TypeScript code, usage patterns, accessibility, testing, and performance tips.