Swish UI
All componentsText Free

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.

Found on