Catalog

Edit Props (live)

TEXT
config
config
config
config
config
config

Source

341 lines
import React from "react";
import {
  AbsoluteFill,
  useCurrentFrame,
  useVideoConfig,
  interpolate,
  Easing,
} from "remotion";
import { loadFont as loadInter } from "@remotion/google-fonts/Inter";
import { loadFont as loadMono } from "@remotion/google-fonts/JetBrainsMono";

loadInter("normal", { weights: ["400", "700", "900"] });
loadMono("normal", { weights: ["400", "500", "700"] });

type LineKind = "cmd" | "out" | "err" | "ok" | "comment" | "blank";

interface TerminalLine {
  text: string;
  kind?: LineKind;
  // Override typing speed for this line
  cps?: number;
}

interface TerminalTypingProps {
  lines: TerminalLine[];
  title?: string;
  backgroundColor?: string;
  // Default typing speed chars-per-sec
  defaultCps?: number;
  // Flavour: "mac", "linux", "ohnie"
  flavour?: "mac" | "linux" | "ohnie";
  prompt?: string;
  // Scale of terminal font
  fontSize?: number;
}

const MONO = "'JetBrains Mono', monospace";
const FONT = "'Inter', sans-serif";

// Simple tokenizer to colorize command lines
function renderCommandTokens(text: string): React.ReactNode {
  const tokens = text.split(/(\s+|['"][^'"]*['"]|\$\w+|--?[\w-]+)/).filter(Boolean);
  return tokens.map((tok, i) => {
    if (/^['"]/.test(tok)) {
      return <span key={i} style={{ color: "#a8d98a" }}>{tok}</span>;
    }
    if (/^--?[\w-]+$/.test(tok)) {
      return <span key={i} style={{ color: "#ffb080" }}>{tok}</span>;
    }
    if (/^\$\w+/.test(tok)) {
      return <span key={i} style={{ color: "#c4a7ff" }}>{tok}</span>;
    }
    if (i === 0 && /\w/.test(tok)) {
      return <span key={i} style={{ color: "#57ceb2", fontWeight: 500 }}>{tok}</span>;
    }
    return <span key={i}>{tok}</span>;
  });
}

export const TerminalTyping: React.FC<TerminalTypingProps> = ({
  lines,
  title,
  backgroundColor = "#0a0612",
  defaultCps = 28,
  flavour = "mac",
  prompt = "~",
  fontSize = 32,
}) => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();
  const t = frame / fps;

  // Compute per-line start/duration in seconds
  const lineMeta: { startT: number; endT: number; chars: number }[] = [];
  let cursor = 0.4; // small chrome-in delay
  for (const line of lines) {
    const cps = line.cps ?? defaultCps;
    const chars = line.text.length;
    const isCmd = (line.kind ?? "cmd") === "cmd";
    const dur = isCmd ? chars / cps : 0.25; // command: type, output: instant
    lineMeta.push({ startT: cursor, endT: cursor + dur, chars });
    cursor += dur + (isCmd ? 0.35 : 0.15);
  }

  // Terminal chrome fade-in
  const chromeOp = interpolate(t, [0, 0.35], [0, 1], { extrapolateRight: "clamp" });
  const chromeScale = interpolate(t, [0, 0.35], [0.96, 1], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  // Terminal box layout — anchored below title, height grows to fit content
  const { width, height } = useVideoConfig();
  const termW = width - 100;
  const lineH = fontSize * 1.55;
  // chrome bar + inner padding + N lines + trailing prompt + bottom buffer
  const visibleLineCount = lines.length + 1;
  const termContentH = 52 + 60 + visibleLineCount * lineH + 80;
  const termX = 50;
  const termY = title ? 220 : 140;
  // Fill remaining vertical space up to a sensible max
  const termH = Math.min(height - termY - 120, Math.max(900, termContentH));

  // Cursor blink
  const cursorBlink = Math.floor(t * 2) % 2 === 0;

  // Title reveal
  const titleReveal = interpolate(frame, [0, 20], [100, 0], {
    extrapolateRight: "clamp",
    easing: Easing.out(Easing.cubic),
  });

  // Scroll-up when content overflows
  const maxVisibleLines = Math.floor((termH - 60) / lineH);

  return (
    <AbsoluteFill
      style={{
        backgroundColor,
        fontFamily: FONT,
        overflow: "hidden",
      }}
    >
      {/* Soft glow backdrop */}
      <div
        style={{
          position: "absolute",
          inset: 0,
          background:
            "radial-gradient(ellipse at 50% 40%, rgba(87,206,178,0.12), transparent 60%)",
          pointerEvents: "none",
        }}
      />

      {/* Title */}
      {title && (
        <div
          style={{
            position: "absolute",
            top: 100,
            left: 60,
            right: 60,
            fontSize: 54,
            fontWeight: 900,
            color: "#fff",
            letterSpacing: -1.2,
            clipPath: `inset(0 ${titleReveal}% 0 0)`,
            WebkitClipPath: `inset(0 ${titleReveal}% 0 0)`,
          }}
        >
          {title}
        </div>
      )}

      {/* Terminal window */}
      <div
        style={{
          position: "absolute",
          left: termX,
          top: termY,
          width: termW,
          height: termH,
          background: "#12101a",
          borderRadius: 22,
          border: "1px solid rgba(255,255,255,0.12)",
          boxShadow: "0 30px 80px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.08)",
          overflow: "hidden",
          opacity: chromeOp,
          transform: `scale(${chromeScale})`,
          transformOrigin: "center center",
        }}
      >
        {/* Title bar */}
        <div
          style={{
            height: 52,
            background: "linear-gradient(to bottom, #1f1a2a, #181424)",
            borderBottom: "1px solid rgba(255,255,255,0.06)",
            display: "flex",
            alignItems: "center",
            padding: "0 20px",
            gap: 10,
          }}
        >
          {flavour !== "ohnie" && (
            <>
              <div style={{ width: 14, height: 14, borderRadius: 7, background: "#ff5f57" }} />
              <div style={{ width: 14, height: 14, borderRadius: 7, background: "#febc2e" }} />
              <div style={{ width: 14, height: 14, borderRadius: 7, background: "#28c840" }} />
            </>
          )}
          {flavour === "ohnie" && (
            <div
              style={{
                width: 22,
                height: 22,
                background: "linear-gradient(135deg, #57ceb2, #509adb)",
                borderRadius: 5,
              }}
            />
          )}
          <div
            style={{
              marginLeft: 14,
              fontFamily: MONO,
              fontSize: 20,
              color: "rgba(255,255,255,0.55)",
              fontWeight: 400,
            }}
          >
            {flavour === "ohnie" ? "ohnie@reel" : flavour === "mac" ? "user@macbook-pro — -zsh" : "user@ubuntu:~"}
          </div>
        </div>

        {/* Terminal body */}
        <div
          style={{
            padding: "24px 30px",
            fontFamily: MONO,
            fontSize,
            lineHeight: 1.55,
            color: "#e8e2f5",
            position: "relative",
            height: "100%",
          }}
        >
          {lines.map((line, idx) => {
            const meta = lineMeta[idx];
            if (!meta) return null;
            if (t < meta.startT) return null;

            const kind = line.kind ?? "cmd";
            const isCmd = kind === "cmd";

            // Character reveal for commands
            let shown = line.text;
            let showCaret = false;
            if (isCmd) {
              const charT = interpolate(t, [meta.startT, meta.endT], [0, 1], {
                extrapolateLeft: "clamp",
                extrapolateRight: "clamp",
              });
              const numChars = Math.floor(charT * line.text.length);
              shown = line.text.slice(0, numChars);
              showCaret = t < meta.endT + 0.15;
            }

            const colorMap: Record<LineKind, string> = {
              cmd: "#ffffff",
              out: "rgba(232,226,245,0.85)",
              err: "#ff7a7a",
              ok: "#57ceb2",
              comment: "rgba(184,158,255,0.65)",
              blank: "transparent",
            };

            // Compute scroll offset so latest line stays in view
            const completedLines = lineMeta.filter((m) => t >= m.startT).length;
            const scrollOff = Math.max(0, completedLines - maxVisibleLines) * lineH;

            return (
              <div
                key={idx}
                style={{
                  display: "flex",
                  alignItems: "baseline",
                  gap: 14,
                  color: colorMap[kind],
                  transform: `translateY(-${scrollOff}px)`,
                  transition: "transform 0.3s ease",
                }}
              >
                {isCmd && (
                  <span style={{ color: "#57ceb2", flexShrink: 0 }}>
                    <span style={{ color: "rgba(87,206,178,0.65)" }}>{prompt}</span>{" "}
                    <span style={{ color: "#57ceb2", fontWeight: 500 }}>$</span>
                  </span>
                )}
                <span
                  style={{
                    whiteSpace: "pre",
                    wordBreak: "break-all",
                  }}
                >
                  {isCmd ? renderCommandTokens(shown) : shown}
                  {showCaret && cursorBlink && (
                    <span
                      style={{
                        display: "inline-block",
                        width: fontSize * 0.5,
                        height: fontSize * 0.9,
                        background: "#57ceb2",
                        marginLeft: 2,
                        verticalAlign: "text-bottom",
                      }}
                    />
                  )}
                </span>
              </div>
            );
          })}

          {/* Final idle cursor after all lines */}
          {(() => {
            const allDone = lineMeta.length > 0 && t > lineMeta[lineMeta.length - 1].endT + 0.3;
            if (!allDone) return null;
            const completedLines = lineMeta.filter((m) => t >= m.startT).length;
            const scrollOff = Math.max(0, completedLines - maxVisibleLines) * lineH;
            return (
              <div
                style={{
                  display: "flex",
                  alignItems: "baseline",
                  gap: 14,
                  color: "#fff",
                  transform: `translateY(-${scrollOff}px)`,
                }}
              >
                <span style={{ color: "rgba(87,206,178,0.65)" }}>{prompt}</span>{" "}
                <span style={{ color: "#57ceb2", fontWeight: 500 }}>$</span>
                {cursorBlink && (
                  <span
                    style={{
                      display: "inline-block",
                      width: fontSize * 0.5,
                      height: fontSize * 0.9,
                      background: "#57ceb2",
                      marginLeft: -6,
                      verticalAlign: "text-bottom",
                    }}
                  />
                )}
              </div>
            );
          })()}
        </div>
      </div>
    </AbsoluteFill>
  );
};

Schema (Zod)

// Auto-extracted from Props interface — see source for authoritative types.
// Component: TerminalTyping
// 7 props detected · 1 port(s)

Terminal Typing

terminal_typing

Terminal Typing — Andy's hand-crafted Remotion scene. 7 props. title:TEXT.

Workflow Inputs (1)

  • Title
    title
    TEXT

Config Fields (6)

  • lines
  • prompt
  • flavour
  • fontSize
  • defaultCps
  • backgroundColor

Meta

Updated
4/21/2026