Text

Typewriter

A type-and-delete headline that cycles through a list of sentences with a blinking caret.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

// step-end: the caret snaps between states — a terminal blink, not a fade
const KEYFRAMES = `@keyframes tw-caret{0%,49%{opacity:1}50%,100%{opacity:0}}`

type Phase = "typing" | "deleting"

interface Tick {
  /** Snapshot of the sentence array — stable while the content is, so a parent re-render doesn't restart the timer */
  items: string[]
  index: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/typewriter.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Typewriter" component (no animation
library — one setTimeout drives everything).

Contract
- Export a forwardRef span extending React.HTMLAttributes<HTMLSpanElement>.
- Props: sequences (string[], typed one after another), typeSpeed (ms per
  character while typing, default 65), deleteSpeed (ms per character while
  deleting, default 35), pauseMs (how long a finished sentence is held,
  default 1600), loop (default true; false = stop on the last sentence and
  never delete it), cursor (default true), cursorChar (default "|"),
  onSequenceComplete?(index) — fired the moment a sentence's last character
  lands, not when its hold ends.
- className lands on the outer span, so font size / weight / color are
  inherited from the call site; the component never sets typography itself.

Behavior
- One state object holds { items, index, count, phase } where phase is
  "typing" | "deleting" and count is how many characters of the current
  sentence are visible. Keep a snapshot of the sequences array inside that
  state so a parent re-render with a fresh array literal does not restart
  the timer; reset the snapshot during render (compare a joined key, no
  effect) when the sentence list actually changes.
- Exactly one setTimeout is scheduled per state, always cleared in the
  effect cleanup — there is never more than one live timer:
  typing & count < len   -> +1 char after typeSpeed
  typing & count = len   -> hold pauseMs, then switch to deleting
                            (if loop is false and this is the last sentence,
                             schedule nothing: the finished sentence is the
                             final frame)
  deleting & count > 0   -> -1 char after deleteSpeed
  deleting & count = 0   -> advance index modulo sequences.length, type again
- onSequenceComplete fires inside the timeout callback that lands the final
  character, so a StrictMode double-invoke is cancelled by the cleanup and
  the callback never double-fires. Keep the consumer's function in a ref and
  refresh it in an effect, so passing an inline arrow never restarts timers.
- prefers-reduced-motion (useSyncExternalStore over matchMedia, server
  snapshot false): render the FIRST sentence in full, start no timers at
  all, and keep the caret solid instead of blinking — the message is still
  delivered, the motion is not.

Rendering & styling
- Semantic tokens only; the component contributes no color of its own —
  it inherits currentColor, so bg-muted / text-muted-foreground wrappers
  and dark mode work for free.
- Accessibility: character-by-character output is noise for screen readers,
  so the animated span is aria-hidden and a sr-only span carries every
  sequence joined into one static string — assistive tech reads the full
  message once instead of tracking a moving target.
- The animated span uses whitespace-pre-wrap so mid-sentence and trailing
  spaces are not collapsed and the caret does not jump a space backwards.
- The caret is a separate aria-hidden span animated by a hoisted @keyframes
  (React 19 <style href precedence>, dedupes by href, no Tailwind config
  edits) using step-end timing for a hard terminal blink, plus
  motion-reduce:[animation:none].

Customization levers
- Rhythm: typeSpeed / deleteSpeed / pauseMs are independent — human typing
  reads well around 60-90ms typing, 25-40ms deleting, 1.2-2s hold.
- Caret: cursorChar takes any glyph ("|", "▌", "_"), cursor={false} removes
  it; swap the blink for a solid caret by dropping the animation class.
- Ending: loop={false} freezes on the last sentence — the idiomatic way to
  type one headline in and leave it on screen.
- Layout stability: the line grows and shrinks as it types; wrap the
  component in a fixed-height (or min-w) container when it sits above other
  content you do not want reflowing.
- Chaining: use onSequenceComplete(index) to drive anything downstream —
  swap a hero image per sentence, count completed lines, fire analytics.

Concepts

  • Type–delete loop — the sentence list is a cycle, not a playlist: every sentence is typed, held, erased, and the index wraps, so one line of copy can carry three or four messages without extra layout.
  • Single-timer state machine — each state schedules exactly one setTimeout and the effect cleanup clears it, so unmounting mid-sentence leaks nothing and speed props take effect on the very next character.
  • Snapshot the sequence list — the array lives inside component state, so a parent re-render passing a new array literal cannot restart the animation; only a real content change resets it (compared during render, no effect).
  • Completion is a character event, not a timer eventonSequenceComplete fires the moment the last character lands, inside the async callback, which also makes it StrictMode-safe (a double-invoked effect is cancelled by its cleanup).
  • Solid caret under reduced motion — the caret keeps its shape but stops blinking and the first sentence is shown whole: the "someone is typing" signal survives without any movement.
  • Static text for screen readers — the animated layer is aria-hidden and an sr-only span holds every sequence joined, so assistive tech hears the full message once instead of a per-character stream.

On This Page