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.

ASOasis
8 min read
Mastering Typewriter Text Animation in React: From CSS to Production‑Ready Hooks

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-only for 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 suppressHydrationWarning and replace its contents on mount.
  • Lazy/dynamic import: For libraries that touch window during import, use dynamic imports or ssr: false in 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/onWordEnd for 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