Typewriter
Phrases type out one character at a time behind a blinking caret, pause, erase and move on to the next.
Build a React typewriter that cycles through the phrases "Ship the idea.", "Polish the details." and "Copy the prompt.". Type one character every 55ms, hold the full phrase for 1.4s, delete one character every 30ms, then start the next phrase. Drive it with a single setTimeout chain that is cleared on unmount. Show a 2px caret after the text that blinks with a CSS steps(1) animation every 1s, and stops blinking while typing. Iterate with Array.from(text) so emoji and CJK characters are never split. Put the full current phrase in aria-label and hide the animated text from screen readers. Under prefers-reduced-motion, show each phrase whole and swap them every 2.5s.
Source
'use client'
import { useEffect, useState } from 'react'
import s from './demo.module.css'
export default function Typewriter({ labels }: { labels: Record<string, string> }) {
const [text, setText] = useState('')
const [typing, setTyping] = useState(false)
const [full, setFull] = useState(labels.phrases.split('|')[0])
useEffect(() => {
const phrases = labels.phrases.split('|')
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
let timer: ReturnType<typeof setTimeout>
let p = 0
let i = 0
let deleting = false
const tick = () => {
const chars = Array.from(phrases[p])
setFull(phrases[p])
if (reduced) {
setText(phrases[p])
p = (p + 1) % phrases.length
timer = setTimeout(tick, 2500)
return
}
if (!deleting && i < chars.length) {
i++
setTyping(true)
timer = setTimeout(tick, 55)
} else if (!deleting) {
deleting = true
setTyping(false)
timer = setTimeout(tick, 1400)
return
} else if (i > 0) {
i--
setTyping(true)
timer = setTimeout(tick, 30)
} else {
deleting = false
p = (p + 1) % phrases.length
timer = setTimeout(tick, 250)
return
}
setText(chars.slice(0, i).join(''))
}
tick()
return () => clearTimeout(timer)
}, [labels.phrases])
return (
<p className={s.line} aria-label={full}>
<span aria-hidden="true">{text}</span>
<span className={typing ? s.caret : `${s.caret} ${s.blink}`} aria-hidden="true" />
</p>
)
}
.line {
display: flex;
align-items: center;
min-height: 1.4em;
margin: 0;
font: 600 var(--text-2xl) / 1.2 var(--font-display);
letter-spacing: -0.02em;
color: var(--stage-ink);
white-space: nowrap;
}
.caret {
width: 2px;
height: 1.05em;
margin-left: 3px;
background: var(--stage-accent);
}
.blink { animation: blink 1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }