reelcn
GitHubBack to landing

Typewriter

Types text out character by character, with pause markers and a caret that blinks once typing stops.

Typewriter, type
Typewriter, type thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/typewriter.json

Files land in src/reelcn/ with the items it builds on. Dependencies: remotion.

Usage

<Center>
  <Typewriter text="Make a launch video^ for vertical" cps={20} />
</Center>

Props

PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse duration for an unrelated local timing. See that item's own prop docs.
exitnumber | booleanExit at the end of the parent `<Sequence>`. false keeps it on screen, a number sets the exit length in frames.
motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to false.
holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to 0.
text*stringText to type. `^` pauses for pause frames, `^20` pauses for 20 frames, `\n` starts a new line.
cpsnumberCharacters per second. A duration wins over it and becomes the total typing time in frames. Default 18.
typingFramesnumberTotal typing time in frames. Wins over duration and cps when set.
pausenumberFrames a bare `^` waits. Defaults to 0.5s.
thenstring[]Phrases typed after text: each one holds, backspaces, then the next types in its place. Default [].
holdPhrasenumberFrames each phrase holds before it is backspaced. Defaults to 1s.
humanizenumber0-1. Seeded speed changes per key and a short rest after punctuation, like a person typing. 0 types evenly. Default 0.
blink"smooth" | "hard"How the caret blinks once typing stops. Default "hard".
caret"none" | "bar" | "block" Default "bar".
sizenumberFont size in design units. Default 72.
weightnumber
font"mono" | "heading" | "body" Default "heading".
colorstring
caretColorstringCaret color. Defaults to the theme accent.
align"left" | "right" | "center" Default "center".
followCaretFollowZooms the camera to trail the caret as it types. Omitted (default): today's behavior, unchanged.
styleCSSProperties
classNamestring

Built from

Use

  • Prompts, search queries and chat-style reveals
  • Quotes and statements that should read at speaking pace

Avoid

Guides

Components take their colors from a theme, their timing from motion props and their layout from the canvas.

Source

registry/items/typewriter.tsx
/**
 * @title Typewriter
 * @category text
 * @description Types text out character by character, with pause markers and a caret that blinks once typing stops.
 * @duration data-driven
 * @use Prompts, search queries and chat-style reveals
 * @use Quotes and statements that should read at speaking pace
 * @avoid Code listings. Use `code-block`
 * @avoid Shell commands with output. Use `terminal`
 * @tags typing, caret, cursor, prompt, type on
 * @example
 * <Center>
 *   <Typewriter text="Make a launch video^ for vertical" cps={20} />
 * </Center>
 */
import type React from "react";
import { random } from "remotion";
import {
  type CaretFollow,
  FollowCaret,
  graphemes,
  type MotionProps,
  useMotion,
  useTextMetrics,
  useTheme,
  useViewport,
} from "./core";

export type TypewriterProps = MotionProps & {
  /** Text to type. `^` pauses for `pause` frames, `^20` pauses for 20 frames, `\n` starts a new line. */
  text: string;
  /** Characters per second. A `duration` wins over it and becomes the total typing time in frames. */
  cps?: number;
  /** Total typing time in frames. Wins over `duration` and `cps` when set. */
  typingFrames?: number;
  /** Frames a bare `^` waits. Defaults to 0.5s. */
  pause?: number;
  /** Phrases typed after `text`: each one holds, backspaces, then the next types in its place. */
  then?: string[];
  /** Frames each phrase holds before it is backspaced. Defaults to 1s. */
  holdPhrase?: number;
  /** 0-1. Seeded speed changes per key and a short rest after punctuation, like a person typing. 0 types evenly. */
  humanize?: number;
  /** How the caret blinks once typing stops. */
  blink?: "hard" | "smooth";
  caret?: "bar" | "block" | "none";
  /** Font size in design units. */
  size?: number;
  weight?: number;
  font?: "heading" | "body" | "mono";
  color?: string;
  /** Caret color. Defaults to the theme accent. */
  caretColor?: string;
  align?: "left" | "center" | "right";
  /** Zooms the camera to trail the caret as it types. Omitted (default): today's behavior, unchanged. */
  follow?: CaretFollow;
  style?: React.CSSProperties;
  className?: string;
};

/** The characters to type, and for each one the extra frames to wait before it appears. */
function parse(text: string, pause: number) {
  const chars: string[] = [];
  const waits: number[] = [];
  let wait = 0;
  text.split(/\^(\d*)/).forEach((part, index) => {
    // split() with a capture group alternates text, marker digits, text, marker digits…
    if (index % 2 === 1) {
      wait += part === "" ? pause : Number(part);
      return;
    }
    for (const char of graphemes(part)) {
      chars.push(char);
      waits.push(wait);
      wait = 0;
    }
  });
  return { chars, waits };
}

export function Typewriter({
  text,
  cps = 18,
  typingFrames,
  pause,
  then = [],
  holdPhrase,
  humanize = 0,
  blink = "hard",
  caret = "bar",
  size = 72,
  weight,
  font = "heading",
  color,
  caretColor,
  align = "center",
  follow,
  style,
  className,
  ...motion
}: TypewriterProps) {
  const theme = useTheme();
  const { u, width, safe } = useViewport();
  const m = useMotion(motion);
  const fontPx = u(size);
  const pauseFrames = pause ?? Math.round(m.fps * 0.5);
  const phrases = [text, ...then].map((phrase) => parse(phrase, pauseFrames));
  const totalChars = phrases.reduce((n, phrase) => n + phrase.chars.length, 0);
  const totalFrames = typingFrames ?? motion.duration;
  const perChar = totalFrames ? totalFrames / Math.max(totalChars, 1) : m.fps / cps;
  const hold = holdPhrase ?? m.fps;
  const keyFrames = (phrase: number, i: number, char: string) =>
    humanize > 0
      ? perChar * (1 + humanize * (random(`typewriter-${phrase}-${i}`) - 0.5)) +
        (/[.,!?;:]/.test(char) ? humanize * m.fps * 0.15 : 0)
      : perChar;

  /** Which phrase shows at `frame`, how many of its characters, and when the caret last moved. */
  const stateAt = (frame: number) => {
    let t = m.delay;
    // Characters the previous phrase left on screen: a phrase only erases back to what the next one shares.
    let kept = 0;
    for (let p = 0; p < phrases.length; p++) {
      const { chars, waits } = phrases[p];
      let typed = kept;
      for (let i = kept; i < chars.length; i++) {
        t += waits[i] + keyFrames(p, i, chars[i]);
        if (frame >= t) typed = i + 1;
      }
      const last = p === phrases.length - 1;
      if (last || frame < t + hold) return { phrase: p, typed, typing: frame >= m.delay && frame < t, end: t };
      // Backspacing runs twice as fast as typing.
      const erase = Math.max(perChar / 2, 0.5);
      const next = phrases[p + 1].chars;
      let shared = 0;
      while (shared < chars.length && chars[shared] === next[shared]) shared++;
      const erased = Math.floor((frame - t - hold) / erase);
      if (erased < chars.length - shared) return { phrase: p, typed: chars.length - erased, typing: true, end: t };
      t += hold + (chars.length - shared) * erase;
      kept = shared;
    }
    return { phrase: 0, typed: 0, typing: false, end: t };
  };

  const now = stateAt(m.frame);
  const { chars } = phrases[now.phrase];
  const typed = now.typed;
  const sinceStop = m.frame - now.end;
  const half = Math.max(1, Math.round(m.fps / 2));
  // Solid while typing, then a blink once a second: an on/off cut like a real cursor, or a soft fade.
  const caretOpacity = now.typing
    ? 1
    : blink === "smooth"
      ? 0.5 + 0.5 * Math.cos((Math.max(sinceStop, 0) / half) * Math.PI)
      : Math.floor(Math.abs(sinceStop) / half) % 2 === 0
        ? 1
        : 0;
  const block = caret === "block";

  // Follow: the caret position `lag` frames ago, from the same reveal logic above evaluated at an
  // earlier frame, always computed (cheap, pure), only measured/used when `follow` is set.
  const laggedFrame = Math.max(m.frame - (follow?.lag ?? 6), m.delay);
  const lagged = stateAt(laggedFrame);
  const laggedText = phrases[lagged.phrase].chars.slice(0, lagged.typed).join("");
  const caretMetrics = useTextMetrics(
    laggedText,
    {
      fontFamily: theme.fonts[font],
      fontSize: fontPx,
      fontWeight: weight ?? (font === "heading" ? theme.headingWeight : 500),
    },
    { skip: !follow },
  );
  const laggedLines = laggedText.split("\n").length - 1;

  // The div is centered horizontally by its usual layout (see the `@example`'s `<Center>`), and its
  // rendered width never changes while typing, the hidden untyped tail below always reserves the full
  // text's space, so the box's real screen-space origin is fixed and derivable from the *full* text's
  // width. Without this, caret.x above was a bare local offset that implicitly assumed the box started
  // flush at the frame's left edge (x=0), which sent `follow`'s zoom off-frame for this, the default,
  // centered rendering (see CONTRIBUTING.md's `follow` guidance).
  const fullTextMetrics = useTextMetrics(
    chars.join(""),
    {
      fontFamily: theme.fonts[font],
      fontSize: fontPx,
      fontWeight: weight ?? (font === "heading" ? theme.headingWeight : 500),
    },
    { skip: !follow },
  );
  const maxTextWidth = width - safe.x * 2;
  const boxWidth = Math.min(fullTextMetrics.width, maxTextWidth);
  const originX = (width - boxWidth) / 2;
  // Past one box-width the text has wrapped a line the canvas measurement above can't see (it measures
  // one continuous run); folding that back into a line count keeps the lagged caret advancing downward
  // instead of running off the right edge, and keeps its x inside the box on every wrapped line.
  const wrappedLines = boxWidth > 0 ? Math.floor(caretMetrics.width / boxWidth) : 0;
  const caretPosition = {
    x: originX + Math.min(caretMetrics.width - wrappedLines * boxWidth, boxWidth),
    y: (laggedLines + wrappedLines) * fontPx * 1.2,
  };

  return (
    <FollowCaret follow={follow} caret={caretPosition}>
      <div
        className={className}
        style={{
          fontFamily: theme.fonts[font],
          fontSize: fontPx,
          fontWeight: weight ?? (font === "heading" ? theme.headingWeight : 500),
          color: color ?? theme.colors.foreground,
          lineHeight: 1.2,
          letterSpacing: font === "mono" ? 0 : "-0.02em",
          textAlign: align,
          textWrap: "balance",
          whiteSpace: "pre-wrap",
          maxWidth: width - safe.x * 2,
          opacity: 1 - m.exit,
          translate: `0 ${-m.exit * fontPx * 0.2}px`,
          ...style,
        }}
      >
        {chars.slice(0, typed).join("")}
        {caret !== "none" && (
          // An empty inline span marks the insertion point without adding a line-break opportunity.
          <span style={{ position: "relative" }}>
            <span
              style={{
                position: "absolute",
                left: block ? 0 : "0.02em",
                top: "0.08em",
                bottom: "0.08em",
                width: block ? "0.56em" : "0.07em",
                background: caretColor ?? theme.colors.accent,
                opacity: caretOpacity * (block ? 0.75 : 1),
              }}
            />
          </span>
        )}
        {/* The untyped rest keeps its space, so wrapping and centering never shift while typing. */}
        <span style={{ visibility: "hidden" }}>{chars.slice(typed).join("")}</span>
      </div>
    </FollowCaret>
  );
}

On this page