Skip to content

Typewriter Text

A typewriter with a human hand: jittered keystrokes, beats at punctuation, a caret that rests only when it waits, accelerating backspace, a reserved width so nothing shifts, and an optional syntax-tinted code mode.

DOM + CSS0 deps1 file · 10 KB TSXreduced-motion aware

Freeeditorialminimal
Preview

Props

60
1400
Loop
No runtime dependencies
"use client";

/*
 * TypewriterText: types, holds, backspaces and cycles through phrases.
 *
 * Cadence is human: every character gets a jittered delay, commas and full
 * stops add a beat, the caret holds still while keys are going down and blinks
 * only when it waits, and backspacing starts with a short beat then accelerates.
 * The box reserves the width of the longest phrase (all phrases are stacked as
 * invisible ghosts in one grid cell), so nothing around it ever shifts. Pauses
 * while off screen, while the tab is hidden and while `paused` is set. The
 * animated text is aria-hidden; the full phrases are read from a sr-only span.
 * Reduced motion swaps typing for a calm cross-fade between whole phrases.
 */

import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";

const DEFAULT_PHRASES = ["Build fast.", "Ship faster.", "Vibe code."];
const COLOR = /^(?:#(?:[0-9a-f]{3}|[0-9a-f]{6})|currentColor)$/i;
const RM = "(prefers-reduced-motion: reduce)";

const CSS = [
  ".twx-caret{animation:none}",
  '.twx-caret[data-s="hold"],.twx-caret[data-s="gap"],.twx-caret[data-s="idle"]{animation:twx-blink 1.1s infinite}',
  "@keyframes twx-blink{0%,49%{opacity:1}50%,100%{opacity:0}}",
  ".twx-fade{animation:twx-in 520ms cubic-bezier(0.2,0.7,0.2,1) both}",
  "@keyframes twx-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}}",
  "@media (prefers-reduced-motion:reduce){.twx-caret{animation:none!important}}",
].join("\n");

type Mode = "type" | "hold" | "erase" | "gap";
type CaretShape = "bar" | "block" | "underscore";

interface View {
  i: number;
  n: number;
  mode: Mode;
}

function subscribeRM(cb: () => void) {
  const m = window.matchMedia(RM);
  m.addEventListener("change", cb);
  return () => m.removeEventListener("change", cb);
}

/** Split into user-perceived characters so backspacing never cuts an emoji in half. */
function split(s: string): string[] {
  const Seg = (Intl as unknown as { Segmenter?: new (l?: string, o?: { granularity: string }) => { segment(x: string): Iterable<{ segment: string }> } }).Segmenter;
  if (Seg) return Array.from(new Seg(undefined, { granularity: "grapheme" }).segment(s), (x) => x.segment);
  return Array.from(s);
}

const TOKEN =
  /(\/\/.*$|#.*$)|("[^"]*"?|'[^']*'?|`[^`]*`?)|(\b\d+(?:\.\d+)?\b)|(\b(?:const|let|var|function|return|import|from|export|default|await|async|if|else|new|type|interface|npm|run|git|push)\b)|([A-Za-z_$][\w$]*(?=\())|([{}()[\];,.:=<>+\-*/!&|?]+)/g;
const TINT = ["", "#7d8590", "#22b07d", "#e0914a", "#a78bfa", "#5b9cf5", "#8b949e"];

/** A very small tokenizer: enough to tint keywords, strings, numbers, calls and punctuation as they appear. */
function tint(text: string): ReactNode[] {
  const out: ReactNode[] = [];
  let last = 0;
  let k = 0;
  for (const m of text.matchAll(TOKEN)) {
    const at = m.index ?? 0;
    if (at > last) out.push(text.slice(last, at));
    const g = m.findIndex((v, idx) => idx > 0 && v !== undefined);
    out.push(
      <span key={k++} style={{ color: TINT[g] }}>
        {m[0]}
      </span>,
    );
    last = at + m[0].length;
  }
  if (last < text.length) out.push(text.slice(last));
  return out;
}

export function TypewriterText({
  phrases = DEFAULT_PHRASES,
  typeSpeed = 60,
  pauseMs = 1400,
  eraseSpeed,
  loop = true,
  code = false,
  caret = "bar",
  caretColor = "#ef4444",
  paused = false,
  onCycle,
  className = "text-3xl font-black tracking-tight",
}: {
  /** Phrases to type, in order. */
  phrases?: string[];
  /** Base delay per typed character, in ms. Each key is jittered around it. */
  typeSpeed?: number;
  /** How long a finished phrase is held before it is erased, in ms. */
  pauseMs?: number;
  /** Base delay per erased character. Defaults to typeSpeed / 2. */
  eraseSpeed?: number;
  /** false types the list once and stops on the last phrase. */
  loop?: boolean;
  /** Monospace and tint keywords, strings, numbers and calls as they are typed. */
  code?: boolean;
  caret?: CaretShape;
  /** Hex colour or currentColor. */
  caretColor?: string;
  /** Freezes the animation in place. */
  paused?: boolean;
  /** Called with the phrase index each time a phrase finishes typing. */
  onCycle?: (index: number) => void;
  /** Replaces the default look "text-3xl font-black tracking-tight". */
  className?: string;
}) {
  const list = phrases.length ? phrases : DEFAULT_PHRASES;
  const key = list.join("\u0001");
  const reduced = useSyncExternalStore(subscribeRM, () => window.matchMedia(RM).matches, () => false);
  const [view, setView] = useState<View>({ i: 0, n: 0, mode: "type" });
  const [away, setAway] = useState(false);
  const root = useRef<HTMLSpanElement>(null);
  const live = useRef({ list, typeSpeed, pauseMs, eraseSpeed, loop, code, paused, onCycle, onscreen: true });
  const wake = useRef<(() => void) | null>(null);

  useEffect(() => {
    live.current = { list, typeSpeed, pauseMs, eraseSpeed, loop, code, paused, onCycle, onscreen: live.current.onscreen };
    wake.current?.();
  });

  useEffect(() => {
    const el = root.current;
    let timer: number | undefined;
    let i = 0;
    let n = 0;
    let mode: Mode = "type";
    let run = 0;
    let finished = false;

    const can = () => live.current.onscreen && !live.current.paused && document.visibilityState === "visible";

    if (reduced) {
      const show = () => setView({ i, n: split(live.current.list[i % live.current.list.length] ?? "").length, mode: "hold" });
      timer = window.setTimeout(function again() {
        show();
        if (!can()) {
          timer = window.setTimeout(again, 500);
          return;
        }
        const len = live.current.list.length;
        live.current.onCycle?.(i % len);
        i = (i + 1) % len;
        timer = len > 1 ? window.setTimeout(again, Math.max(live.current.pauseMs + 900, 2400)) : undefined;
      }, 0);
      return () => window.clearTimeout(timer);
    }

    const tick = () => {
      timer = undefined;
      if (finished || !can()) return;
      const c = live.current;
      const len = c.list.length;
      const segs = split(c.list[i % len] ?? "");
      const base = Math.max(c.typeSpeed, 5);
      let delay = base;
      if (mode === "type") {
        n = Math.min(n + 1, segs.length);
        const ch = segs[n - 1] ?? "";
        if (n >= segs.length) {
          mode = "hold";
          delay = c.pauseMs;
          c.onCycle?.(i % len);
        } else {
          delay = base * (0.55 + Math.random() * 0.9);
          if (ch === " ") delay *= 1.15;
          if (",;:".includes(ch)) delay += base * 4;
          else if (".!?…—".includes(ch)) delay += base * 7;
          else if (c.code && "{(".includes(ch)) delay += base * 2;
        }
      } else if (mode === "hold") {
        if (!c.loop && i >= len - 1) {
          finished = true;
          return;
        }
        mode = "erase";
        run = 0;
        delay = 170;
      } else if (mode === "erase") {
        n = Math.max(0, n - 1);
        run += 1;
        const e = Math.max(c.eraseSpeed ?? base / 2, 5);
        if (n === 0) {
          mode = "gap";
          delay = 300;
        } else delay = Math.max(12, e * Math.max(0.35, 1 - run * 0.05));
      } else {
        i = (i + 1) % len;
        mode = "type";
        delay = base * 2;
      }
      setView({ i, n, mode });
      timer = window.setTimeout(tick, delay);
    };

    wake.current = () => {
      if (timer === undefined && !finished && can()) timer = window.setTimeout(tick, 80);
    };
    timer = window.setTimeout(tick, 380);

    const io = el
      ? new IntersectionObserver(
          ([e]) => {
            live.current.onscreen = !!e?.isIntersecting;
            setAway(!live.current.onscreen);
            wake.current?.();
          },
          { rootMargin: "100px" },
        )
      : null;
    if (el) io?.observe(el);
    const vis = () => wake.current?.();
    document.addEventListener("visibilitychange", vis);
    return () => {
      window.clearTimeout(timer);
      io?.disconnect();
      document.removeEventListener("visibilitychange", vis);
      wake.current = null;
    };
    // key restarts the cycle when the phrase list changes; everything else is read live.
  }, [key, reduced]);

  const phrase = list[view.i % list.length] ?? "";
  const shown = split(phrase).slice(0, view.n).join("");
  const state = reduced ? "idle" : paused || away ? "idle" : view.mode === "type" || view.mode === "erase" ? "type" : view.mode;
  const color = COLOR.test(caretColor) ? caretColor : "#ef4444";
  const shape: CaretShape = caret === "block" || caret === "underscore" ? caret : "bar";
  const caretBox =
    shape === "bar"
      ? { width: "0.08em", minWidth: 2, height: "1em", verticalAlign: "-0.12em", marginLeft: "0.06em" }
      : shape === "block"
        ? { width: "0.55em", height: "1.02em", verticalAlign: "-0.14em", marginLeft: "0.06em", opacity: 0.85 }
        : { width: "0.6em", height: "0.12em", verticalAlign: "baseline", marginLeft: "0.06em" };

  return (
    <>
      <style>{CSS}</style>
      <span
        ref={root}
        className={"relative inline-grid max-w-full align-baseline " + className}
        style={code ? { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" } : undefined}
      >
        <span className="sr-only">{list.join(" ")}</span>
        <span aria-hidden="true" className="contents">
          {list.map((p, idx) => (
            <span key={idx} className="invisible col-start-1 row-start-1 break-words whitespace-pre-wrap">
              {p}
              <span className="inline-block" style={{ ...caretBox, background: "transparent" }} />
            </span>
          ))}
          <span key={reduced ? view.i : "live"} className={"col-start-1 row-start-1 break-words whitespace-pre-wrap" + (reduced ? " twx-fade" : "")}>
            {code ? tint(shown) : shown}
            <span
              className="twx-caret inline-block"
              data-s={state}
              style={{ ...caretBox, background: color, borderRadius: shape === "bar" ? 1 : 0 }}
            />
          </span>
        </span>
      </span>
    </>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this text effect

A cycling typewriter for headlines, prompt placeholders and code samples. One timer drives a four-step loop (type, hold, erase, gap) and every key gets its own delay: `typeSpeed` jittered between 55% and 145%, spaces slightly slower, a four-key beat after commas, semicolons and colons and a seven-key beat after full stops, question marks and dashes. Backspacing starts with a 170ms beat, then accelerates from `eraseSpeed` (default half of `typeSpeed`) down to 35% of it. The caret holds still while keys are going down and blinks (1.1s, hard on/off) only when it waits. Every phrase is stacked as an invisible ghost in one grid cell, so the component is always as wide and as tall as the longest phrase and nothing around it shifts. The cycle sleeps while the text is off screen, the tab is hidden or `paused` is set, and wakes where it left off. `code` switches to a monospace face and tints keywords, strings, numbers, calls and punctuation as they are typed. Backspace is grapheme-aware, so emoji are never cut in half. The colour is `currentColor` and the caret defaults to the original red, so it sits on a light page as well as a dark one. Props: `phrases`, `typeSpeed`, `pauseMs`, `eraseSpeed`, `loop`, `code`, `caret` (bar, block or underscore), `caretColor`, `paused`, `onCycle` and `className`. React only, no dependencies.

Curator’s note

Rebuilt in October to type like a person: uneven keystrokes, a beat at every comma and full stop, and a box that reserves the longest phrase so the layout never moves.

Pairs well with