Typewriter
Typewriter effect that cycles through phrases with variable typing speed and blinking cursor. Pauses when out of viewport.
Last updated Mar 5, 2026
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
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/typewriter.json"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. |
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}
/>;