Text
Text
Typewriter
Types text out character by character, with pause markers and a caret that blinks once typing stops.
Install
Usage
<Center>
<Typewriter text="Make a launch video^ for vertical" cps={20} />
</Center>Props
| Name | Type | Default | Description |
|---|---|---|---|
delay? | number | — | Frames to wait before entering. |
duration? | number | — | Enter duration in frames. Defaults to 0.6s. |
exit? | number | boolean | — | Exit at the end of the parent `<Sequence>`. `false` keeps it on screen, a number sets the exit length in frames. |
motion? | MotionPreset | — | Override the theme's motion personality. |
text | string | — | Text to type. `^` pauses for `pause` frames, `^20` pauses for 20 frames, `\n` starts a new line. |
cps? | number | 18 | Characters per second. A `duration` wins over it and becomes the total typing time in frames. |
pause? | number | — | Frames a bare `^` waits. Defaults to 0.5s. |
caret? | "none" | "bar" | "block" | "bar" | |
size? | number | 72 | Font size in design units. |
weight? | number | — | |
font? | "mono" | "heading" | "body" | "heading" | |
color? | string | — | |
caretColor? | string | — | Caret color. Defaults to the theme accent. |
align? | "left" | "right" | "center" | "center" | |
style? | CSSProperties | — | |
className? | string | — |
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
Dependencies
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 { graphemes, type MotionProps, useMotion, 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;
/** Frames a bare `^` waits. Defaults to 0.5s. */
pause?: number;
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";
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,
pause,
caret = "bar",
size = 72,
weight,
font = "heading",
color,
caretColor,
align = "center",
style,
className,
...motion
}: TypewriterProps) {
const theme = useTheme();
const { u, width, safe } = useViewport();
const m = useMotion(motion);
const fontPx = u(size);
const { chars, waits } = parse(text, pause ?? Math.round(m.fps * 0.5));
const perChar = motion.duration ? motion.duration / Math.max(chars.length, 1) : m.fps / cps;
let typed = 0;
let end = m.delay;
for (let i = 0; i < chars.length; i++) {
end += waits[i] + perChar;
if (m.frame >= end) typed = i + 1;
}
// Solid while typing, then a hard on/off blink once a second, like a real text cursor.
const typing = m.frame >= m.delay && m.frame < end;
const caretOn = typing || Math.floor(Math.abs(m.frame - end) / (m.fps / 2)) % 2 === 0;
const block = caret === "block";
return (
<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: caretOn ? (block ? 0.75 : 1) : 0,
}}
/>
</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>
);
}