Typewriter
A phrase types out behind a blinking caret, pauses, deletes itself and types the next one.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const PHRASES = ["a long weekend in Comporta", "the whole family in August", "remote work by the sea"];
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const text = useRef<HTMLSpanElement>(null);
const state = useRef({ phrase: 0, chars: PHRASES[0].length, deleting: false });
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
useEffect(() => {
// Elements are read once here: a timer can still run after unmount, when refs are already null.
const [el, out] = [root.current, text.current];
if (!el || !out || !inView || reduced) return;
const s = state.current;
let timer = 0;
const tick = () => {
const phrase = PHRASES[s.phrase];
let wait: number;
if (!s.deleting) {
if (s.chars < phrase.length) { s.chars++; wait = 45 + Math.random() * 70; }
else { s.deleting = true; wait = 1800; }
} else if (s.chars > 0) { s.chars--; wait = 28; }
else { s.deleting = false; s.phrase = (s.phrase + 1) % PHRASES.length; wait = 400; }
out.textContent = PHRASES[s.phrase].slice(0, s.chars);
el.classList.toggle(styles.typing, wait < 200);
timer = window.setTimeout(tick, wait);
};
timer = window.setTimeout(tick, 600);
return () => clearTimeout(timer);
}, [inView, reduced]);
return (
<div ref={root} className={styles.root}>
<h2 className={styles.title}>
<span className={styles.sr}>Stays for {PHRASES.join(", ")}.</span>
<span aria-hidden="true">
Stays for
<span className={styles.line}>
<span ref={text}>{PHRASES[0]}</span>
<span className={styles.caret} />
</span>
</span>
</h2>
<p className={styles.sub}>318 villas across Portugal and Spain</p>
</div>
);
}
demo.module.css
.root { container-type: size; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 20px; text-align: center; }
.title { font-size: clamp(24px, min(6.6cqi, 11cqh), 52px); line-height: 1.15; letter-spacing: -0.03em; font-weight: 650; min-height: 3.45em; display: grid; align-content: center; }
.line { display: block; max-width: 13em; color: var(--accent-text); }
.caret { display: inline-block; width: 0.07em; height: 0.95em; margin-left: 0.06em; vertical-align: -0.12em; border-radius: 2px; background: var(--accent-text); animation: blink 1s steps(1) infinite; }
.typing .caret { animation: none; }
@keyframes blink { 50% { opacity: 0; } }
.sub { font-size: 14px; color: var(--muted); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .caret { animation: none; } }
Use it for
- A hero line that shows a few real use cases in one spot
- Search field placeholders that suggest queries
- Chat or assistant products, where typing is the product
Avoid
- Anything the user must read to act, they may land mid delete
- More than one typing line on a page
How it works
A chain of setTimeout calls adds or removes one character and writes textContent through a ref, so React never re renders per keystroke. Typing waits 45 to 115ms per character for a human rhythm, deleting is faster, and a full phrase holds for 1.8s. A class switches off the caret blink while characters are moving. The chain stops when the demo leaves the viewport and resumes from where it was.
Accessibility
The heading holds a visually hidden sentence with every phrase, and the typed text is aria-hidden, so screen readers hear one stable sentence instead of a stream of changes. Reduced motion shows the first phrase with a still caret and no timers.