Skip to content
Uxelith
Sign in
Freev1.0.0Text & Motion

Typewriter

Text that types itself out with a human rhythm, a caret that holds steady while typing and blinks when idle, and words that are backspaced or selected and replaced. Starts when scrolled into view; screen readers get the sentence once.

pnpm dlx shadcn add …/r/typewriter.json
Licence
MIT
Packages
2
Updated
10 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/typewriter.json
  2. It adds these packages for you:

    clsxtailwind-merge
  3. The source lands in your components folder as typewriter.tsx. It's yours: change anything.

Usage

example.tsx
import { Typewriter } from "@/components/typewriter"

export function Hero() {
  return (
    <Typewriter
      as="h1"
      prefix="Interfaces for"
      words={["designers", "developers", "founders"]}
      className="text-5xl font-semibold tracking-tight"
    />
  )
}

Source

One file, 180 lines, nothing hidden. Read it before you install it.

typewriter.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

export interface TypewriterProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
  /** Words or phrases typed one after another. */
  words: string[];
  /** Static text before the typed words, e.g. "Built for". */
  prefix?: string;
  /** Element to render, so headings stay headings. */
  as?: "h1" | "h2" | "h3" | "h4" | "p" | "span";
  /** Milliseconds per typed character, on average. */
  typingSpeed?: number;
  /** Milliseconds per deleted character. */
  deletingSpeed?: number;
  /** Milliseconds a finished word stays before it is erased. */
  pause?: number;
  /** How a word goes: deleted letter by letter, or selected and replaced at once. */
  erase?: "backspace" | "select";
  /** Type with a human rhythm: uneven keystrokes and a beat after spaces and punctuation. */
  humanize?: boolean;
  /** Keep cycling. Off by default: it types each word once and stays on the last. */
  loop?: boolean;
  /** Show a caret after the text. It holds steady while typing and blinks when idle. */
  caret?: boolean;
}

function usePrefersReducedMotion() {
  return React.useSyncExternalStore(
    (notify) => {
      const query = window.matchMedia("(prefers-reduced-motion: reduce)");
      query.addEventListener("change", notify);
      return () => query.removeEventListener("change", notify);
    },
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
    () => false,
  );
}

// A steady pseudo-random rhythm (the same on every run, so it can be tested).
const jitter = (word: number, index: number) => {
  const x = Math.sin(word * 97.13 + index * 12.9898) * 43758.5453;
  return x - Math.floor(x);
};

type Phase = "typing" | "holding" | "erasing" | "selected";

/**
 * Text that types itself out, word after word, like someone is at the keyboard.
 *
 * - A human rhythm: keystrokes vary, with a beat after spaces and
 *   punctuation. The caret holds steady while typing and blinks when idle.
 * - `erase="select"` highlights the word and replaces it in one go, the way
 *   you would in an editor, instead of backspacing.
 * - It starts when it scrolls into view and, when looping, pauses under the pointer.
 * - Screen readers get the whole sentence once ("Built for designers,
 *   developers and founders"); the typing is hidden from them.
 * - Under `prefers-reduced-motion` the last word simply shows.
 * - The caret's blink needs the `ux-typewriter-caret` keyframes, which the registry adds.
 */
export function Typewriter({
  words,
  prefix,
  as: Tag = "span",
  typingSpeed = 70,
  deletingSpeed = 40,
  pause = 1400,
  erase = "backspace",
  humanize = true,
  loop = false,
  caret = true,
  className,
  onPointerEnter,
  onPointerLeave,
  ...props
}: TypewriterProps) {
  const ref = React.useRef<HTMLElement>(null);
  const reduceMotion = usePrefersReducedMotion();
  const [state, setState] = React.useState<{ word: number; length: number; phase: Phase }>({ word: 0, length: 0, phase: "typing" });
  const [paused, setPaused] = React.useState(false);
  const [visible, setVisible] = React.useState(false);
  const list = words.length ? words : [""];
  const index = Math.min(state.word, list.length - 1);
  const current = list[index]!;
  const isLast = index === list.length - 1;
  const done = !loop && isLast && state.length >= current.length && state.phase !== "erasing" && state.phase !== "selected";

  // Start typing when it comes into view (at once where that can't be known).
  React.useEffect(() => {
    const node = ref.current;
    if (!node || typeof IntersectionObserver === "undefined") {
      const timer = window.setTimeout(() => setVisible(true));
      return () => window.clearTimeout(timer);
    }
    const observer = new IntersectionObserver(([entry]) => {
      if (entry?.isIntersecting) {
        setVisible(true);
        observer.disconnect();
      }
    });
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  React.useEffect(() => {
    if (reduceMotion || done || paused || !visible) return;
    const { length, phase } = state;
    const next = { word: (index + 1) % list.length, length: 0, phase: "typing" as Phase };
    let delay: number;
    let after = state;
    if (phase === "typing" && length < current.length) {
      const typed = current[length]!;
      const beat = humanize ? (typed === " " ? 1.8 : /[,.!?;:]/.test(current[length - 1] ?? "") ? 2.4 : 0.55 + jitter(index, length) * 0.9) : 1;
      delay = typingSpeed * beat;
      after = { ...state, length: length + 1 };
    } else if (phase === "typing") {
      delay = pause;
      after = { ...state, phase: erase === "select" ? "selected" : "erasing" };
    } else if (phase === "selected") {
      // Hold the highlight for a moment, then the new word replaces it.
      delay = Math.max(pause * 0.35, 250);
      after = next;
    } else if (length > 0) {
      delay = deletingSpeed;
      after = { ...state, length: length - 1 };
    } else {
      delay = typingSpeed * 2;
      after = next;
    }
    const timer = window.setTimeout(() => setState(after), delay);
    return () => window.clearTimeout(timer);
  }, [state, current, index, list.length, done, paused, visible, reduceMotion, typingSpeed, deletingSpeed, pause, erase, humanize]);

  // Every word once, for screen readers and search engines.
  const spoken = [prefix, list.length > 1 ? `${list.slice(0, -1).join(", ")} and ${list.at(-1)}` : list[0]].filter(Boolean).join(" ");
  const shown = reduceMotion ? list.at(-1)! : current.slice(0, state.length);
  const selected = !reduceMotion && state.phase === "selected";
  const idle = done || paused || state.phase === "selected" || (state.phase === "typing" && state.length >= current.length);

  return (
    <Tag
      ref={ref as React.Ref<never>}
      className={cn("[overflow-wrap:anywhere]", className)}
      onPointerEnter={(event) => {
        onPointerEnter?.(event);
        if (loop) setPaused(true);
      }}
      onPointerLeave={(event) => {
        onPointerLeave?.(event);
        setPaused(false);
      }}
      {...props}
    >
      <span className="sr-only">{spoken}</span>
      <span aria-hidden="true">
        {prefix && <>{prefix} </>}
        <span
          className={cn(
            "rounded-[0.12em] text-[var(--typewriter-color,inherit)] transition-colors duration-150",
            selected && "bg-[color-mix(in_oklab,var(--typewriter-color,var(--primary))_30%,transparent)]",
          )}
        >
          {shown}
        </span>
        {caret && !reduceMotion && (
          <span
            className={cn(
              "ml-0.5 inline-block h-[1em] w-[0.08em] min-w-0.5 translate-y-[0.12em] rounded-full bg-[var(--typewriter-color,currentColor)]",
              // Steady while keys are being pressed, blinking when idle, as in an editor.
              idle && "animate-[ux-typewriter-caret_1s_steps(1)_infinite]",
              selected && "opacity-0",
            )}
          />
        )}
      </span>
    </Tag>
  );
}

Props

PropTypeDefaultDescription
wordsstring[]—Words or phrases typed one after another.
prefixstring—Static text before the typed words.
as"h1" | "h2" | "h3" | "h4" | "p" | "span""span"Element to render.
typingSpeed / deletingSpeednumber70 / 40Milliseconds per character.
pausenumber1400Milliseconds a finished word stays.
erase"backspace" | "select""backspace"Delete letter by letter, or highlight the word and replace it at once.
humanizebooleantrueUneven keystrokes and a beat after spaces and punctuation.
loopbooleanfalseKeep cycling instead of stopping on the last word.
caretbooleantrueShow a blinking caret.
--typewriter-colorCSS variableinheritColour of the typed words and caret.

Accessibility

  • Screen readers get the sentence once ("Interfaces for designers, developers and founders"); the typing is hidden from them.
  • Stops on the last word by default; when looping it pauses while the pointer is over it.
  • Under prefers-reduced-motion the last word simply shows, with no caret.

Changelog

  1. v1.0.0Latest

    First release.

More in Text & Motion

All 4 in Text & Motion
  • Text & MotionFree

    Blur Text

    Text that comes into focus word by word or letter by letter, then brings out the words you choose: they take the accent colour while a line draws beneath them. Screen readers get the sentence once.

  • Text & MotionFree

    Marquee

    Content that loops past across or up and down with no gaps, at a steady speed, with soft faded edges. Pauses on hover and keyboard focus.

  • Text & MotionFree

    Shimmer Text

    Text with a band of light that sweeps across it, sized to the text so short labels and long headings move at the same pace. Settles to plain text when the work is done. Pure CSS, renders on the server.