Text Effects

Typewriter

Types text character by character with a blinking cursor, showing the full string immediately under reduced motion.

Install

npx shadcn@latest add @paragon/typewriter

typewriter.tsx

"use client";

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

type Phase = "idle" | "typing" | "holding" | "deleting" | "done";

/**
 * Deterministic per-character pacing — a short rest after punctuation reads
 * like a human breath. Pure function of the character, no randomness.
 */
const PAUSE_AFTER: Record<string, number> = {
  ",": 3,
  ";": 3,
  ":": 3,
  ".": 8,
  "!": 8,
  "?": 8,
  "…": 8,
  "\n": 8,
};

function delayAfter(prev: string | undefined, base: number): number {
  return base * ((prev !== undefined && PAUSE_AFTER[prev]) || 1);
}

function usePrefersReducedMotion(): boolean {
  const subscribe = React.useCallback((onChange: () => void) => {
    const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
    mql.addEventListener("change", onChange);
    return () => mql.removeEventListener("change", onChange);
  }, []);
  return React.useSyncExternalStore(
    subscribe,
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
    () => false,
  );
}

export interface TypewriterProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** The string to type — or several, cycled through in order. */
  text: string | string[];
  /** Milliseconds per character while typing. */
  speed?: number;
  /** Milliseconds per character while deleting. */
  deleteSpeed?: number;
  /** Milliseconds the finished string holds before deleting (when cycling). */
  holdDelay?: number;
  /** Milliseconds before the first character appears. */
  startDelay?: number;
  /** Cycle forever. Defaults to true when `text` is an array of 2+ strings. */
  loop?: boolean;
  /** Show the blinking caret. */
  cursor?: boolean;
  /** Renders the full string with no typing and no blink. */
  static?: boolean;
}

/**
 * Types text character by character with a blinking caret. The caret stays
 * solid while typing and blinks only at rest, like a real editor. Reserves
 * the footprint of the longest string, so surrounding layout never shifts.
 * Under `prefers-reduced-motion` (or `static`) the full string renders
 * immediately; typing pauses while the element is offscreen.
 */
export function Typewriter({
  text,
  speed = 40,
  deleteSpeed = 24,
  holdDelay = 2200,
  startDelay = 0,
  loop,
  cursor = true,
  static: isStatic = false,
  className,
  ref,
  ...props
}: TypewriterProps) {
  const textsKey = Array.isArray(text) ? text.join("\u0000") : text;
  const texts = React.useMemo(
    () => (Array.isArray(text) ? text : [text]),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [textsKey],
  );
  const shouldLoop = loop ?? texts.length > 1;

  const reducedMotion = usePrefersReducedMotion();
  const skip = isStatic || reducedMotion;

  const [phase, setPhase] = React.useState<Phase>("idle");
  const [count, setCount] = React.useState(0);
  const [textIndex, setTextIndex] = React.useState(0);
  const [inView, setInView] = React.useState(false);

  const localRef = React.useRef<HTMLSpanElement | null>(null);
  const setRefs = (node: HTMLSpanElement | null) => {
    localRef.current = node;
    if (typeof ref === "function") ref(node);
    else if (ref) ref.current = node;
  };

  // Pause the loop while offscreen (hard rule for anything that runs forever).
  React.useEffect(() => {
    const el = localRef.current;
    if (!el || typeof IntersectionObserver === "undefined") {
      setInView(true);
      return;
    }
    const io = new IntersectionObserver(
      (entries) => setInView(entries[0]?.isIntersecting ?? true),
      { rootMargin: "48px" },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const current = texts[textIndex % texts.length] ?? "";
  const lastText = textIndex >= texts.length - 1;

  React.useEffect(() => {
    if (skip || !inView) return;
    let timeout: ReturnType<typeof setTimeout> | undefined;

    if (phase === "idle") {
      timeout = setTimeout(() => setPhase("typing"), startDelay);
    } else if (phase === "typing") {
      if (count < current.length) {
        timeout = setTimeout(
          () => setCount((c) => Math.min(c + 1, current.length)),
          delayAfter(current[count - 1], speed),
        );
      } else if (shouldLoop || !lastText) {
        setPhase("holding");
      } else {
        setPhase("done");
      }
    } else if (phase === "holding") {
      timeout = setTimeout(() => setPhase("deleting"), holdDelay);
    } else if (phase === "deleting") {
      if (count > 0) {
        timeout = setTimeout(
          () => setCount((c) => Math.max(c - 1, 0)),
          deleteSpeed,
        );
      } else {
        setTextIndex((i) => (i + 1) % texts.length);
        setPhase("typing");
      }
    }

    return () => clearTimeout(timeout);
  }, [
    phase,
    count,
    textIndex,
    inView,
    skip,
    current,
    lastText,
    shouldLoop,
    texts.length,
    speed,
    deleteSpeed,
    holdDelay,
    startDelay,
  ]);

  const displayed = skip ? current : current.slice(0, count);

  const caret = (live: boolean) => (
    <span
      aria-hidden="true"
      data-tw-cursor={live ? "" : undefined}
      className={cn(
        "pointer-events-none ml-[0.12em] inline-block h-[1em] w-[0.075em] min-w-[1.5px] translate-y-[0.14em] rounded-full bg-current",
        !live && "invisible",
      )}
    />
  );

  return (
    <>
      <style href="paragon-typewriter" precedence="paragon">{`
        @keyframes pg-tw-blink {
          0%, 100% { opacity: 1; }
          50% { opacity: 0; }
        }
        [data-tw]:is([data-phase="idle"], [data-phase="holding"], [data-phase="done"]) [data-tw-cursor] {
          animation: pg-tw-blink 1s step-end infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          [data-tw] [data-tw-cursor] { animation: none !important; }
        }
      `}</style>
      <span
        ref={setRefs}
        data-slot="typewriter"
        data-tw=""
        data-phase={skip ? "done" : phase}
        className={cn("inline-grid align-baseline", className)}
        {...props}
      >
        {/* Invisible copies of every string reserve the widest/tallest
            footprint, so typing never shifts surrounding layout. */}
        {texts.map((t, i) => (
          <span
            key={i}
            aria-hidden="true"
            className="invisible col-start-1 row-start-1 whitespace-pre-wrap"
          >
            {t}
            {cursor && caret(false)}
          </span>
        ))}
        {/* Screen readers get the full string immediately. */}
        <span className="sr-only">{current}</span>
        <span
          aria-hidden="true"
          className="col-start-1 row-start-1 whitespace-pre-wrap"
        >
          {displayed}
          {cursor && !isStatic && caret(true)}
        </span>
      </span>
    </>
  );
}