Workspace / component / text

Typewriter Text

Types characters at a natural cadence with a blinking block caret, then rests. Supports multiple phrases with erase and retype.

Workspacecomponenttext

paxfx add blocks/component-text-typewriterlocal only

Tier: low. Reduced motion: Full phrase renders instantly with a steady caret.

"use client";

import { useEffect, useMemo, useState } from "react";
import { cn, useInView, useReducedMotion } from "@pax-sh/blocks";

export interface TypewriterTextProps {
  /** Phrases typed in order. With more than one, each is erased and retyped. */
  phrases?: string[];
  /** Heading level or inline element to render. */
  as?: "h1" | "h2" | "h3" | "p" | "span";
  /** Base milliseconds per typed character. */
  typeMs?: number;
  /** Milliseconds per erased character. */
  eraseMs?: number;
  /** Milliseconds a completed phrase rests before erasing. */
  holdMs?: number;
  className?: string;
}

const DEFAULT_PHRASES = ["Design once.", "Ship everywhere.", "Stay fast."];

function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/**
 * Types characters at a natural cadence with a blinking block caret, then
 * rests. Cadence jitter comes from a seeded PRNG so every run reads the
 * same. A single phrase types once and rests; multiple phrases erase and
 * retype on a loop driven by one rAF that cancels on unmount.
 */
export function TypewriterText({
  phrases = DEFAULT_PHRASES,
  as: Tag = "h2",
  typeMs = 62,
  eraseMs = 30,
  holdMs = 1700,
  className,
}: TypewriterTextProps) {
  const reduced = useReducedMotion();
  const [ref, inView] = useInView<HTMLSpanElement>({ threshold: 0.4 });
  const [display, setDisplay] = useState("");
  const longest = useMemo(
    () => phrases.reduce((a, b) => (b.length > a.length ? b : a), ""),
    [phrases],
  );

  useEffect(() => {
    if (reduced) {
      setDisplay(phrases[0] ?? "");
      return;
    }
    if (!inView) return;

    let raf = 0;
    let last = performance.now();
    let wait = 0;
    let phraseIdx = 0;
    let charCount = 0;
    let phase: "typing" | "holding" | "erasing" = "typing";
    const rand = mulberry32(0x7ad5f2c3);
    const nextDelay = () => typeMs * (0.7 + rand() * 0.7);
    let delay = nextDelay();

    const step = (now: number) => {
      raf = requestAnimationFrame(step);
      wait += Math.min(now - last, 100);
      last = now;
      const phrase = phrases[phraseIdx] ?? "";

      if (phase === "typing") {
        while (wait >= delay && charCount < phrase.length) {
          wait -= delay;
          charCount += 1;
          delay = nextDelay();
        }
        if (charCount >= phrase.length) {
          if (phrases.length <= 1) {
            setDisplay(phrase);
            cancelAnimationFrame(raf);
            return;
          }
          phase = "holding";
          wait = 0;
        }
      } else if (phase === "holding") {
        if (wait >= holdMs) {
          phase = "erasing";
          wait = 0;
        }
      } else {
        while (wait >= eraseMs && charCount > 0) {
          wait -= eraseMs;
          charCount -= 1;
        }
        if (charCount === 0) {
          phraseIdx = (phraseIdx + 1) % phrases.length;
          phase = "typing";
          wait = 0;
          delay = nextDelay();
        }
      }
      setDisplay(phrase.slice(0, charCount));
    };

    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [inView, reduced, phrases, typeMs, eraseMs, holdMs]);

  return (
    <Tag
      className={cn("m-0 font-semibold tracking-tight", className)}
      data-pax-asset="component-text-typewriter"
    >
      <style>{`
        @keyframes pax-typewriter-blink {
          0%, 49% { opacity: 1; }
          50%, 100% { opacity: 0; }
        }
      `}</style>
      <span className="sr-only">{phrases.join(" ")}</span>
      <span ref={ref} aria-hidden="true" className="inline-grid">
        <span className="invisible col-start-1 row-start-1 whitespace-pre">{`${longest}\u00A0`}</span>
        <span className="col-start-1 row-start-1 whitespace-pre">
          {display}
          <span
            className="ml-[0.08em] inline-block h-[1em] w-[0.5ch] translate-y-[0.12em] bg-current"
            style={
              reduced ? undefined : { animation: "pax-typewriter-blink 1.06s steps(1) infinite" }
            }
          />
        </span>
      </span>
    </Tag>
  );
}