Typewriter
Types text out character by character, with pause markers and a caret that blinks once typing stops.
Typewriter, type
Installation
npx shadcn add https://www.reelcn.dev/r/typewriter.jsonFiles 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
- Code listings. Use
code-block - Shell commands with output. Use
terminal
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>
);
}