Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/typewriter.jsonIt adds these packages for you:
clsxtailwind-mergeThe source lands in your components folder as
typewriter.tsx. It's yours: change anything.
Usage
import { Typewriter } from "@/components/typewriter"
export function Hero() {
return (
<Typewriter
as="h1"
prefix="Interfaces for"
words={["designers", "developers", "founders"]}
className="text-5xl font-semibold tracking-tight"
/>
)
}Source
One file, 180 lines, nothing hidden. Read it before you install it.
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TypewriterProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** Words or phrases typed one after another. */
words: string[];
/** Static text before the typed words, e.g. "Built for". */
prefix?: string;
/** Element to render, so headings stay headings. */
as?: "h1" | "h2" | "h3" | "h4" | "p" | "span";
/** Milliseconds per typed character, on average. */
typingSpeed?: number;
/** Milliseconds per deleted character. */
deletingSpeed?: number;
/** Milliseconds a finished word stays before it is erased. */
pause?: number;
/** How a word goes: deleted letter by letter, or selected and replaced at once. */
erase?: "backspace" | "select";
/** Type with a human rhythm: uneven keystrokes and a beat after spaces and punctuation. */
humanize?: boolean;
/** Keep cycling. Off by default: it types each word once and stays on the last. */
loop?: boolean;
/** Show a caret after the text. It holds steady while typing and blinks when idle. */
caret?: boolean;
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
(notify) => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
query.addEventListener("change", notify);
return () => query.removeEventListener("change", notify);
},
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
// A steady pseudo-random rhythm (the same on every run, so it can be tested).
const jitter = (word: number, index: number) => {
const x = Math.sin(word * 97.13 + index * 12.9898) * 43758.5453;
return x - Math.floor(x);
};
type Phase = "typing" | "holding" | "erasing" | "selected";
/**
* Text that types itself out, word after word, like someone is at the keyboard.
*
* - A human rhythm: keystrokes vary, with a beat after spaces and
* punctuation. The caret holds steady while typing and blinks when idle.
* - `erase="select"` highlights the word and replaces it in one go, the way
* you would in an editor, instead of backspacing.
* - It starts when it scrolls into view and, when looping, pauses under the pointer.
* - Screen readers get the whole sentence once ("Built for designers,
* developers and founders"); the typing is hidden from them.
* - Under `prefers-reduced-motion` the last word simply shows.
* - The caret's blink needs the `ux-typewriter-caret` keyframes, which the registry adds.
*/
export function Typewriter({
words,
prefix,
as: Tag = "span",
typingSpeed = 70,
deletingSpeed = 40,
pause = 1400,
erase = "backspace",
humanize = true,
loop = false,
caret = true,
className,
onPointerEnter,
onPointerLeave,
...props
}: TypewriterProps) {
const ref = React.useRef<HTMLElement>(null);
const reduceMotion = usePrefersReducedMotion();
const [state, setState] = React.useState<{ word: number; length: number; phase: Phase }>({ word: 0, length: 0, phase: "typing" });
const [paused, setPaused] = React.useState(false);
const [visible, setVisible] = React.useState(false);
const list = words.length ? words : [""];
const index = Math.min(state.word, list.length - 1);
const current = list[index]!;
const isLast = index === list.length - 1;
const done = !loop && isLast && state.length >= current.length && state.phase !== "erasing" && state.phase !== "selected";
// Start typing when it comes into view (at once where that can't be known).
React.useEffect(() => {
const node = ref.current;
if (!node || typeof IntersectionObserver === "undefined") {
const timer = window.setTimeout(() => setVisible(true));
return () => window.clearTimeout(timer);
}
const observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) {
setVisible(true);
observer.disconnect();
}
});
observer.observe(node);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
if (reduceMotion || done || paused || !visible) return;
const { length, phase } = state;
const next = { word: (index + 1) % list.length, length: 0, phase: "typing" as Phase };
let delay: number;
let after = state;
if (phase === "typing" && length < current.length) {
const typed = current[length]!;
const beat = humanize ? (typed === " " ? 1.8 : /[,.!?;:]/.test(current[length - 1] ?? "") ? 2.4 : 0.55 + jitter(index, length) * 0.9) : 1;
delay = typingSpeed * beat;
after = { ...state, length: length + 1 };
} else if (phase === "typing") {
delay = pause;
after = { ...state, phase: erase === "select" ? "selected" : "erasing" };
} else if (phase === "selected") {
// Hold the highlight for a moment, then the new word replaces it.
delay = Math.max(pause * 0.35, 250);
after = next;
} else if (length > 0) {
delay = deletingSpeed;
after = { ...state, length: length - 1 };
} else {
delay = typingSpeed * 2;
after = next;
}
const timer = window.setTimeout(() => setState(after), delay);
return () => window.clearTimeout(timer);
}, [state, current, index, list.length, done, paused, visible, reduceMotion, typingSpeed, deletingSpeed, pause, erase, humanize]);
// Every word once, for screen readers and search engines.
const spoken = [prefix, list.length > 1 ? `${list.slice(0, -1).join(", ")} and ${list.at(-1)}` : list[0]].filter(Boolean).join(" ");
const shown = reduceMotion ? list.at(-1)! : current.slice(0, state.length);
const selected = !reduceMotion && state.phase === "selected";
const idle = done || paused || state.phase === "selected" || (state.phase === "typing" && state.length >= current.length);
return (
<Tag
ref={ref as React.Ref<never>}
className={cn("[overflow-wrap:anywhere]", className)}
onPointerEnter={(event) => {
onPointerEnter?.(event);
if (loop) setPaused(true);
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
setPaused(false);
}}
{...props}
>
<span className="sr-only">{spoken}</span>
<span aria-hidden="true">
{prefix && <>{prefix} </>}
<span
className={cn(
"rounded-[0.12em] text-[var(--typewriter-color,inherit)] transition-colors duration-150",
selected && "bg-[color-mix(in_oklab,var(--typewriter-color,var(--primary))_30%,transparent)]",
)}
>
{shown}
</span>
{caret && !reduceMotion && (
<span
className={cn(
"ml-0.5 inline-block h-[1em] w-[0.08em] min-w-0.5 translate-y-[0.12em] rounded-full bg-[var(--typewriter-color,currentColor)]",
// Steady while keys are being pressed, blinking when idle, as in an editor.
idle && "animate-[ux-typewriter-caret_1s_steps(1)_infinite]",
selected && "opacity-0",
)}
/>
)}
</span>
</Tag>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| words | string[] | — | Words or phrases typed one after another. |
| prefix | string | — | Static text before the typed words. |
| as | "h1" | "h2" | "h3" | "h4" | "p" | "span" | "span" | Element to render. |
| typingSpeed / deletingSpeed | number | 70 / 40 | Milliseconds per character. |
| pause | number | 1400 | Milliseconds a finished word stays. |
| erase | "backspace" | "select" | "backspace" | Delete letter by letter, or highlight the word and replace it at once. |
| humanize | boolean | true | Uneven keystrokes and a beat after spaces and punctuation. |
| loop | boolean | false | Keep cycling instead of stopping on the last word. |
| caret | boolean | true | Show a blinking caret. |
| --typewriter-color | CSS variable | inherit | Colour of the typed words and caret. |
Accessibility
- Screen readers get the sentence once ("Interfaces for designers, developers and founders"); the typing is hidden from them.
- Stops on the last word by default; when looping it pauses while the pointer is over it.
- Under prefers-reduced-motion the last word simply shows, with no caret.
Changelog
- v1.0.0Latest
First release.