Types a phrase, pauses, backspaces and types the next, with a human rhythm and a caret that blinks only at rest.
We build
design systems, landing pages, online stores
Customize
Usage
import { Typewriter } from "@/components/motionkit/typewriter";
<h1>
We build{" "}
<Typewriter
words={["design systems", "landing pages", "online stores"]}
/>
</h1>Motion
The exact curves this component runs, plotted with Motion's own maths.
Typing
Scrolls into view
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/typewriter.tsx.
"use client";
import { useEffect, useRef, useState, type ElementType } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type TypewriterProps = {
/** Phrases to type, in order. */
words: string[];
/** Milliseconds per typed character, on average. */
typeSpeed?: number;
/** Milliseconds per deleted character. */
deleteSpeed?: number;
/** Milliseconds a finished phrase stays before it is deleted. */
pause?: number;
/** Go back to the first phrase after the last; otherwise stop on the last. */
loop?: boolean;
/** Vary the rhythm the way a person types: quicker runs, a beat after punctuation. */
humanize?: boolean;
caret?: boolean;
/** Element to render, e.g. "span". */
as?: ElementType;
className?: string;
};
export function Typewriter({
words,
typeSpeed = 70,
deleteSpeed = 35,
pause = 1600,
loop = true,
humanize = true,
caret = true,
as: Tag = "span",
className = "",
}: TypewriterProps) {
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { once: true });
const reduceMotion = useReducedMotion();
const [state, setState] = useState({ word: 0, length: 0, deleting: false });
const phrase = words[state.word % words.length] ?? "";
useEffect(() => {
if (!inView || reduceMotion || !words.length) return;
const typed = state.length === phrase.length;
const isLast = state.word === words.length - 1;
if (typed && !state.deleting && isLast && !loop) return;
const previous = phrase[state.length - 1];
let delay: number;
if (state.deleting) delay = deleteSpeed;
else if (typed) delay = pause;
else if (!humanize) delay = typeSpeed;
// between half and one-and-a-half the average, with a short beat after punctuation and spaces
else delay = typeSpeed * (0.5 + Math.random()) + (previous && /[.,!?]/.test(previous) ? typeSpeed * 4 : previous === " " ? typeSpeed * 0.6 : 0);
const timer = window.setTimeout(() => {
setState((current) => {
if (current.deleting)
return current.length === 0
? { word: (current.word + 1) % words.length, length: 0, deleting: false }
: { ...current, length: current.length - 1 };
if (current.length === phrase.length) return { ...current, deleting: true };
return { ...current, length: current.length + 1 };
});
}, delay);
return () => window.clearTimeout(timer);
}, [inView, reduceMotion, state, phrase, words.length, typeSpeed, deleteSpeed, pause, loop, humanize]);
// reduced motion shows the first phrase whole, with no typing
const shown = reduceMotion ? (words[0] ?? "") : phrase.slice(0, state.length);
return (
<Tag ref={ref} className={className}>
{/* screen readers get every phrase once, never a character at a time */}
<span className="sr-only">{words.join(", ")}</span>
<span aria-hidden>
{shown}
{caret && (
<motion.span
className="ml-[0.05em] inline-block w-[0.08em] translate-y-[0.1em] bg-current"
style={{ height: "1em" }}
// the caret holds steady while typing and only blinks when the typist rests
animate={reduceMotion || (!state.deleting && state.length < phrase.length) ? { opacity: 1 } : { opacity: [1, 1, 0, 0] }}
transition={{ duration: 1, repeat: Infinity, times: [0, 0.5, 0.5, 1] }}
/>
)}
</span>
</Tag>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| words | string[] | — | Phrases to type, in order. |
| typeSpeed | number | 70 | Average milliseconds per typed character. |
| deleteSpeed | number | 35 | Milliseconds per deleted character. |
| pause | number | 1600 | Milliseconds a finished phrase stays. |
| loop | boolean | true | Start again after the last phrase. |
| humanize | boolean | true | Vary the rhythm like a person typing. |
| caret | boolean | true | Show the caret. |
Think beyond ordinary.
Gradient Text
Headline text with a slowly flowing, seamlessly looping gradient.
Small details, big impact.
Kinetic Text
Headlines that rise letter by letter, slide up line by line, blur into focus or light up as you scroll.
Liquid light
Liquid Gradient Text
Pools of colour swirl and flow inside the letters, for headings that feel alive without moving an inch.