Typewriter Effect for React
Typewriter effect that cycles through phrases with variable typing speed and blinking cursor. Pauses when out of viewport.
Component
"use client";
import { motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface TypewriterProps {
phrases: readonly string[];
className?: string;
cursorClassName?: string;
trigger?: "mount" | "inView";
typeSpeed?: number;
deleteSpeed?: number;
pauseDuration?: number;
cursorBlinkDuration?: number;
cursorBlinkEasing?:
| "easeInOut"
| "easeIn"
| "easeOut"
| "linear"
| [number, number, number, number];
inViewThreshold?: number;
}
export function Typewriter({
phrases,
className,
cursorClassName,
trigger = "inView",
typeSpeed = 100,
deleteSpeed = 50,
pauseDuration = 2000,
cursorBlinkDuration = 0.5,
cursorBlinkEasing = "easeInOut",
inViewThreshold = 0.3,
}: TypewriterProps) {
const [phraseIndex, setPhraseIndex] = useState(0);
const [display, setDisplay] = useState("");
const [deleting, setDeleting] = useState(false);
const [inView, setInView] = useState(trigger === "mount");
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (trigger !== "inView") {
return;
}
const el = ref.current;
if (!el) {
return;
}
const obs = new IntersectionObserver(
([e]) => setInView(e?.isIntersecting ?? false),
{
threshold: inViewThreshold,
},
);
obs.observe(el);
return () => obs.disconnect();
}, [trigger, inViewThreshold]);
const shouldRun = trigger === "mount" ? true : inView;
useEffect(() => {
if (!shouldRun) {
return;
}
const phrase = phrases[phraseIndex] ?? "";
if (deleting) {
if (display.length > 0) {
const t = setTimeout(
() => setDisplay(display.slice(0, -1)),
deleteSpeed,
);
return () => clearTimeout(t);
}
setDeleting(false);
setPhraseIndex((phraseIndex + 1) % phrases.length);
return;
}
if (display.length < phrase.length) {
const t = setTimeout(
() =>
setDisplay(phrase.slice(0, display.length + 1)),
typeSpeed + Math.random() * 40,
);
return () => clearTimeout(t);
}
const t = setTimeout(
() => setDeleting(true),
pauseDuration,
);
return () => clearTimeout(t);
}, [
shouldRun,
display,
deleting,
phraseIndex,
phrases,
typeSpeed,
deleteSpeed,
pauseDuration,
]);
const cursorTransition = {
duration: cursorBlinkDuration,
ease: cursorBlinkEasing,
repeat: Number.POSITIVE_INFINITY,
};
return (
<span ref={ref} className={cn(className)}>
{display}
<motion.span
animate={{ opacity: [1, 0] }}
transition={cursorTransition}
className={cn(
"inline-block min-h-[1em] w-0.5 shrink-0 bg-current align-middle",
cursorClassName,
)}
aria-hidden={true}
/>
</span>
);
}Installation
1. Install dependencies
pnpm add framer-motion2. Copy the component file
"use client";
import { motion } from "framer-motion";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface TypewriterProps {
phrases: readonly string[];
className?: string;
cursorClassName?: string;
trigger?: "mount" | "inView";
typeSpeed?: number;
deleteSpeed?: number;
pauseDuration?: number;
cursorBlinkDuration?: number;
cursorBlinkEasing?:
| "easeInOut"
| "easeIn"
| "easeOut"
| "linear"
| [number, number, number, number];
inViewThreshold?: number;
}
export function Typewriter({
phrases,
className,
cursorClassName,
trigger = "inView",
typeSpeed = 100,
deleteSpeed = 50,
pauseDuration = 2000,
cursorBlinkDuration = 0.5,
cursorBlinkEasing = "easeInOut",
inViewThreshold = 0.3,
}: TypewriterProps) {
const [phraseIndex, setPhraseIndex] = useState(0);
const [display, setDisplay] = useState("");
const [deleting, setDeleting] = useState(false);
const [inView, setInView] = useState(trigger === "mount");
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (trigger !== "inView") {
return;
}
const el = ref.current;
if (!el) {
return;
}
const obs = new IntersectionObserver(
([e]) => setInView(e?.isIntersecting ?? false),
{
threshold: inViewThreshold,
},
);
obs.observe(el);
return () => obs.disconnect();
}, [trigger, inViewThreshold]);
const shouldRun = trigger === "mount" ? true : inView;
useEffect(() => {
if (!shouldRun) {
return;
}
const phrase = phrases[phraseIndex] ?? "";
if (deleting) {
if (display.length > 0) {
const t = setTimeout(
() => setDisplay(display.slice(0, -1)),
deleteSpeed,
);
return () => clearTimeout(t);
}
setDeleting(false);
setPhraseIndex((phraseIndex + 1) % phrases.length);
return;
}
if (display.length < phrase.length) {
const t = setTimeout(
() =>
setDisplay(phrase.slice(0, display.length + 1)),
typeSpeed + Math.random() * 40,
);
return () => clearTimeout(t);
}
const t = setTimeout(
() => setDeleting(true),
pauseDuration,
);
return () => clearTimeout(t);
}, [
shouldRun,
display,
deleting,
phraseIndex,
phrases,
typeSpeed,
deleteSpeed,
pauseDuration,
]);
const cursorTransition = {
duration: cursorBlinkDuration,
ease: cursorBlinkEasing,
repeat: Number.POSITIVE_INFINITY,
};
return (
<span ref={ref} className={cn(className)}>
{display}
<motion.span
animate={{ opacity: [1, 0] }}
transition={cursorTransition}
className={cn(
"inline-block min-h-[1em] w-0.5 shrink-0 bg-current align-middle",
cursorClassName,
)}
aria-hidden={true}
/>
</span>
);
}3. Import and use
import { Typewriter } from "@/components/typewriter";
<Typewriter phrases={["Hello", "World"]} />;Usage
Import
Add the Typewriter import.
import { Typewriter } from "@/components/typewriter";Use
Use with phrases array.
<Typewriter phrases={["Phrase 1", "Phrase 2"]} />;Guidelines
- trigger='mount': starts immediately on mount. trigger='inView' (default): only animates when in viewport, pauses when scrolled away.
- Pass an array of phrases. The component types each, pauses, deletes, then moves to the next.
- Adjust typeSpeed, deleteSpeed, and pauseDuration for different feels.
- Use cursorBlinkDuration and cursorBlinkEasing for cursor animation.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| phrases | readonly string[] | - | Array of phrases to cycle through. |
| className | string | - | Additional CSS classes for the container. |
| trigger | "mount" | "inView" | "inView" | "mount" = start on mount. "inView" = only animate when in viewport, pause when scrolled away. |
| cursorClassName | string | - | Classes for the blinking cursor (e.g. w-1, bg-amber-500). |
| typeSpeed | number | 100 | Base delay between characters in ms. |
| deleteSpeed | number | 50 | Delay when deleting in ms. |
| pauseDuration | number | 2000 | Pause at end of phrase before deleting in ms. |
| cursorBlinkDuration | number | 0.5 | Cursor blink cycle duration in seconds. |
| cursorBlinkEasing | string | number[] | "easeInOut" | Cursor blink easing: easeInOut, easeIn, easeOut, or cubic-bezier [0.4,0,0.2,1]. |
| inViewThreshold | number | 0.3 | IntersectionObserver threshold (0–1) for pausing when out of view. |
Accessibility
- Decorative text effect with no interactive controls, keyboard handlers, or focusable elements, so there is nothing for keyboard or focus management to operate on.
- The blinking cursor is correctly marked aria-hidden so assistive technology ignores the visual caret.
- The animated text renders as plain text content inside a span with no aria-live region, so screen readers may announce partial or rapidly changing strings rather than the final phrase.
- The component does not honour prefers-reduced-motion, so the continuous typing, deleting, and cursor blink should be guarded with a matchMedia('(prefers-reduced-motion: reduce)') check to render static text for users who prefer reduced motion.
Performance
- The cursor blink animates only opacity via framer-motion, which is compositor-friendly and avoids layout or paint work.
- The typing and deleting effect mutates React state on each character and re-renders the span text, so it changes DOM text content rather than animating a cheap transform property.
- Each step is driven by a single setTimeout that is cleared in the effect cleanup, and the cursor loops with repeat Infinity, so timers are tidied up on unmount or dependency change.
- An IntersectionObserver gates the animation when trigger is inView and pauses work while off screen, and the observer is disconnected on cleanup to avoid leaks.
- There is no reduced-motion gating, so the timers and cursor animation keep running for users who would prefer no motion.
Examples
In view (default)
Animates when scrolled into view, pauses when out of view.
import { Typewriter } from "@/components/typewriter";
<Typewriter
phrases={["Build fast.", "Ship faster.", "Sleep never."]}
trigger="inView"
className="font-bold text-2xl"
/>;On mount
Starts animating on mount, no viewport check.
import { Typewriter } from "@/components/typewriter";
<Typewriter
phrases={["Starts immediately"]}
trigger="mount"
className="font-bold text-2xl"
/>;Custom timing
Custom typing and delete speeds.
import { Typewriter } from "@/components/typewriter";
<Typewriter
phrases={["Developer", "Designer", "Creator"]}
typeSpeed={80}
deleteSpeed={40}
pauseDuration={1500}
/>;Cursor customization
Slower blink, custom cursor width and color.
import { Typewriter } from "@/components/typewriter";
<Typewriter
phrases={["Hello World"]}
cursorBlinkDuration={0.8}
cursorBlinkEasing="easeInOut"
cursorClassName="w-1 bg-amber-500"
/>;Cursor & viewport
Custom cursor class and viewport threshold.
import { Typewriter } from "@/components/typewriter";
<Typewriter
phrases={["Visible text"]}
cursorClassName="w-1 bg-primary"
inViewThreshold={0.5}
/>;Related reading
- Typewriter Effect in React — The full build tutorial for this effect
- Choreographing Multi-Step Motion — Timing the cursor and character reveal
Last updated on Jun 19