Typewriter

Phrases type out one character at a time behind a blinking caret, pause, erase and move on to the next.

  • JS
  • Text
  • Loop
  • Added

AI prompt

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

Demo.tsx
'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>
  )
}
demo.module.css
.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; } }

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.