Typewriter

A monospaced typewriter intro: characters reveal one at a time with a blinking caret, ending with an optional subtitle that fades in below.

Preview

Open editor

When to use

A character-by-character typewriter reveal in a monospaced face, with a caret that blinks once the headline lands. Reads as developer-y, terminal-coded, "live demo" — perfect for product hunts, dev tool intros, or any moment where the headline benefits from feeling actively typed.

Headline length matters more here than in slide-up — every character costs frames. For a 12-character headline at the default speed (2 frames/char) the headline finishes around frame ~32. If your composition duration is short, shorten the headline rather than racing the typing.

Props

NameTypeDefault
headlinestring"hello, world."
subtitlestring"now typing live"

Composition

ID
TitleType
Resolution
1920×1080
FPS
60
Duration
1.7s

Source

Copy or download the React source — drop it into your own Remotion project. The only runtime dependency is remotion.

"use client";
import { AbsoluteFill, Easing, interpolate } from "remotion";
import { useDesignFrame } from "../../use-design-frame";
import { useFontReady } from "../../use-font-ready";
import {
  getSubtitleColor,
  resolveTitleStyle,
  snap,
  type TitleProps,
} from "../title-shared";

export type TitleTypeProps = TitleProps;

const APPLE_EASE = Easing.bezier(0.16, 1, 0.3, 1);

const HEADLINE_START = 8;
const FRAMES_PER_CHAR = 2;
const SUBTITLE_DELAY = 24;
const SUBTITLE_DURATION = 26;
const CURSOR_BLINK_FRAMES = 18;

export const TitleType: React.FC<TitleTypeProps> = ({
  headline,
  subtitle,
  clipStyle,
}) => {
  const frame = useDesignFrame();
  const s = resolveTitleStyle(clipStyle);
  useFontReady(s.fontFamily);
  const elapsed = Math.max(0, frame - HEADLINE_START);
  const visibleChars = Math.min(
    headline.length,
    Math.floor(elapsed / FRAMES_PER_CHAR),
  );
  const headlineDone = visibleChars >= headline.length;
  const headlineEnd = HEADLINE_START + headline.length * FRAMES_PER_CHAR;
  const subtitleStart = headlineEnd + SUBTITLE_DELAY;

  const subtitleProgress = interpolate(
    frame,
    [subtitleStart, subtitleStart + SUBTITLE_DURATION],
    [0, 1],
    {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
      easing: APPLE_EASE,
    },
  );

  const cursorOn = headlineDone
    ? Math.floor(frame / CURSOR_BLINK_FRAMES) % 2 === 0
    : true;

  return (
    <AbsoluteFill
      style={{
        background: s.background,
        color: s.color,
        fontFamily: s.fontFamily,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        padding: "0 80px",
        textAlign: "center",
      }}
    >
      <h1
        style={{
          fontSize: 132,
          fontWeight: 700,
          letterSpacing: "-0.045em",
          lineHeight: 1.05,
          margin: 0,
          whiteSpace: "pre-wrap",
        }}
      >
        {headline.slice(0, visibleChars)}
        <span
          aria-hidden
          style={{
            display: "inline-block",
            width: "0.06em",
            height: "0.95em",
            marginLeft: "0.08em",
            verticalAlign: "-0.12em",
            background: s.color,
            opacity: cursorOn ? 1 : 0,
          }}
        />
      </h1>

      {subtitle.trim() && (
        <p
          style={{
            fontSize: 38,
            fontWeight: 400,
            letterSpacing: "-0.012em",
            margin: "32px 0 0",
            color: getSubtitleColor(s.color),
            opacity: subtitleProgress,
            transform: `translate3d(0, ${snap((1 - subtitleProgress) * 14)}px, 0)`,
          }}
        >
          {subtitle}
        </p>
      )}
    </AbsoluteFill>
  );
};
Save as TitleType/TitleType.tsx