Remotion 文字排版动画实战:打字机、光标闪烁、词轮播与双层高亮全解
Remotion 允许你用 React 组件以逐帧渲染的方式编程式生成视频,而文字排版动画(kinetic typography)正是这类视频中最常用也最容易出错的一类场景。本篇技术指南以官方模板 template-prompt-to-motion-graphics 中的排版技能文档为骨架,系统讲解四类核心文字动画的正确实现姿势:打字机效果、光标闪烁、词轮播与关键词高亮,并针对每一类给出"常见错误写法"与"推荐写法"的对照。读完本文,你将掌握一套可直接复制、可稳定运行、不会出现光标错位、布局抖动与闪烁刺眼的 Remotion 文字动画实现范式,同时理解底层 frame 驱动与 interpolate() 插值的配合原理。
本指南的原始依据位于 typography.md,它在模板中被标记为 impact: HIGH,用于修复常见的文字动画缺陷并提升可读性;同目录下还有 charts、messaging、spring-physics 等平行技能文档,以及多个可直接运行的排版示例源码,共同构成了该模板"AI 提示词 → Remotion 代码"生成链路中的专业知识库。
这套排版技能在仓库中的定位
在深入具体写法前,有必要先说明这份技能的"使用语境"。template-prompt-to-motion-graphics 是一个"提示词转动态图形"(Prompt to Motion Graphics)的模板,其整体流水线是 User Prompt → Validation → Skill Detection → Code Generation → Sanitization → Live Preview(见 README.md)。
其中 Skill Detection 阶段会把用户自然语言提示词分类到若干技能域中,再动态地把对应技能文档注入代码生成模型的提示上下文。技能的注册与分发都在 index.ts 中完成:
GUIDANCE_SKILLS数组收录了typography等 8 个"指导型技能"(Guidance Skill),本质是领域最佳实践的模式库,typography.md 就是其中之一;EXAMPLE_SKILLS收录了example-typewriter-highlight、example-word-carousel等"示例型技能"(Example Skill),提供可完整运行的代码样板;getCombinedSkillContent()负责把命中的多个技能内容用---分隔拼装后注入提示词。
在 SKILL_DETECTION_PROMPT 中,typography 的触发语义被描述为 "kinetic text, typewriter effects, text animations, word carousels, animated titles, text-heavy content"。而 prompts.ts 中也内置了一个典型的 typewriter 示例提示词(要求文字从左到右淡入、模拟打字机、带闪烁光标、并对 "world" 一词做黄色高亮)。因此本指南覆盖的打字机、光标、轮播词、高亮,正好就是这个技能文档要解决的全部核心命题。
打字机效果:坚持"字符串切片",放弃"逐字透明度"
打字机效果的目标是让文本像被逐字敲出一样逐帧增长。实现路径有两条:一是把文本 split("") 成字符数组,逐字符渲染并靠 opacity 控制可见性;二是每次渲染直接对完整字符串做 slice,只输出"已经打出来的那一部分"。
技能文档明确给出了结论:永远使用字符串切片(string slicing)实现打字机,绝不要用逐字符 opacity。原因在于:opacity 为 0 的元素依然占据布局空间。如果一开始就把全部字符的 <span> 渲染进文档流,文本行的布局宽度在打字机启动前就已铺满;此时若把光标定位在"最后一个已打印字符"之后,就不得不逐字测量每个字符的宽度来推算光标 x 坐标——一旦字号、字距、字体渲染发生变化,定位就会出错。
错误写法(逐字符 opacity,会破坏光标定位)
{
text
.split("")
.map((char, i) => (
<span style={{ opacity: i < typedCount ? 1 : 0 }}>{char}</span>
));
}
<span>|</span>;
这段代码里所有字符从第 0 帧起就存在于 DOM 中,未打印字符只是"看不见"。文本总宽度从一开始就是定值,光标(<span>|</span>)无论放在哪里都需要额外计算,最终要么钉死在全文末尾,要么在打字过程中出现错位。
正确写法(字符串切片,光标自然跟随文本)
const typedText = FULL_TEXT.slice(0, typedChars);
<span>{typedText}</span>
<span style={{ opacity: caretOpacity }}>▌</span>
字符串切片让 DOM 中"真实存在"的字符恰好等于已打印的字符,浏览器会自然地完成剩余排版:随着 typedChars 增大,文本内容与宽度同步增长,光标只需要作为普通文本紧跟 <span>{typedText}</span> 之后,即可永远贴合最后一个字符,无需任何手动测量。
仓库内的完整实现 typewriter-highlight.ts 演示了标准的切片节奏控制:
const CHAR_FRAMES = 3;
const typedChars = Math.min(
FULL_TEXT.length,
Math.floor(frame / CHAR_FRAMES),
);
const typedText = FULL_TEXT.slice(0, typedChars);
const typingDone = typedChars >= FULL_TEXT.length;
这里的 frame 来自 Remotion 的 useCurrentFrame() 钩子——Remotion 中动画完全由当前帧号驱动,Math.floor(frame / CHAR_FRAMES) 表示"每 3 帧敲出一个新字符",因此打字速度与视频的 fps 解耦、稳定可预测。值得注意的两个细节:
- 用
Math.min()把typedChars上限定在FULL_TEXT.length,避免切片越界; - 引入布尔量
typingDone,打字结束后即可让光标淡出(示例中通过{!typingDone && ...}条件渲染实现),形成"打完收尾"的干净观感。
光标闪烁:用插值平滑淡化,不做生硬开合
打字机光标需要一个持续的"可见 → 不可见 → 可见"闪烁。最容易写出的版本是用 Math.floor(frame / 15) % 2 之类的奇偶判断做硬切换,但视觉结果是光标突然消失又突然出现,在逐帧视频里会显得刺眼、廉价。
错误写法(硬切换闪烁)
const caretVisible = Math.floor(frame / 15) % 2 === 0;
<span style={{ opacity: caretVisible ? 1 : 0 }}>|</span>;
正确写法(在一个周期内平滑淡化)
const CURSOR_BLINK_FRAMES = 16;
const caretOpacity = interpolate(
frame % CURSOR_BLINK_FRAMES,
[0, CURSOR_BLINK_FRAMES / 2, CURSOR_BLINK_FRAMES],
[1, 0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
<span style={{ opacity: caretOpacity }}>▌</span>;
核心思路:用 frame % CURSOR_BLINK_FRAMES 构造一个长度 16 帧的循环周期,再把"周期内相位"映射到透明度曲线 [1, 0, 1]——前半周期(第 0–8 帧)光标从完全不透明渐隐到透明,后半周期(第 8–16 帧)再渐显回来,形成柔和的呼吸感而非硬闪。{ extrapolateLeft: "clamp", extrapolateRight: "clamp" } 保证相位在边界处不产生越界外推(interpolate 的行为可参见 Remotion 核心实现 packages/core/src/interpolate.ts),不会在 0 帧或 16 帧附近出现透明度跳到负值或超过 1 的反常闪烁。
在 typewriter-highlight.ts 的完整示例中,这个闪烁光标还与上文的打字机切片组合使用:光标仅在打字未完成时渲染({!typingDone && ...}),当整句打完后就停止闪烁并随画面消失,避免"敲完了光标还在原地闪"的尴尬。
词轮播(Word Carousel):用最长词撑住容器宽度
词轮播是标题动画中非常常见的形态:一个固定的前缀文案(如 "Created for")后面跟着一组在多个关键词("Creators / Marketers / Developers")之间循环切换的加粗词。这类动画最容易出的问题是布局抖动——每次换词,容器宽度跟着词长变化,导致后方的文字或整体构图左右跳动。
错误写法(宽度随单词长度跳跃)
<div style={{ position: "relative" }}>
<span>{WORDS[currentIndex]}</span>
</div>
正确写法(用最长词建立一个稳定宽度容器)
const longestWord = WORDS.reduce(
(a, b) => (a.length >= b.length ? a : b),
WORDS[0],
);
<div style={{ position: "relative" }}>
<div style={{ visibility: "hidden" }}>{longestWord}</div>
<div style={{ position: "absolute", left: 0, top: 0 }}>
{WORDS[currentIndex]}
</div>
</div>;
这一段的技法要点非常精细:
- 用
WORDS.reduce((a, b) => (a.length >= b.length ? a : b), WORDS[0])提前求出词表中长度最长的那个词; - 把它渲染在一个
visibility: "hidden"的层里。hidden不可见但依旧参与布局,因此父容器宽度被最长的词永久固定下来; - 真正展示的当前词则放在
position: "absolute"的层中,相对父容器左上角(0, 0)定位。由于绝对定位脱离文档流,无论当前词长短如何,都不会再影响容器的宽度。
visibility: "hidden" 是这个方案的关键——如果换成 display: "none",占位层完全不参与布局,固定宽度的目的就落空了。
仓库中的 word-carousel.ts 提供了一个升级版的词轮播,把"最长词撑宽"技术与"换词过渡"动画结合起来,其切换时序划分得非常清晰:
const HOLD_DURATION = 32; // 当前词停留帧数
const FLIP_DURATION = 18; // 换词交叉淡化帧数
const perStep = HOLD_DURATION + FLIP_DURATION;
const currentStep = Math.floor(frame / perStep) % totalSteps;
const nextStep = (currentStep + 1) % totalSteps;
const phase = frame % perStep;
const isFlipping = phase >= HOLD_DURATION;
在此基础上它又叠加了两层过渡:
- 旧词透明度从 1 渐隐到 0(
outOpacity),新词从 0 渐显到 1(inOpacity),两词用position: "absolute"叠在同一位置做交叉淡化; - 配合模糊滤镜(
filter: blur()),旧词渐出时模糊度从 0 增到 6px,新词从 6px 归零,形成"聚焦换词"的现代感。
这种"停留 + 翻转"的两段式时间轴是词轮播的通用骨架,你可以调整 HOLD_DURATION/FLIP_DURATION 控制节奏。若希望过渡更简单,也可参考 text-rotation.ts 中的单层实现——它用 Math.floor(frame / WORD_DURATION) % WORDS.length 切换下标、配合透明度/缩放/模糊三路 interpolate 做转场;但要注意它没有稳定的占位宽度层,各词宽度不同时仍可能出现布局变化,这正是本技能文档建议引入最长词占位层的原因。
关键词高亮:用"双层交叉淡化"代替生硬跳变
许多文字动画需要在文字打完以后,把其中某个关键词"点亮"(加背景色高亮)。如果直接在同一层上从"无高亮"瞬时切到"有高亮",颜色会在某一帧发生突变。技能文档给出的推荐做法是维护两个重叠的文本层:一层是打字中的原始文本,另一层是带高亮的最终文本,通过两层透明度的反向插值做交叉淡化(crossfade)。
const typedOpacity = interpolate(
frame,
[highlightStart - 8, highlightStart + 8],
[1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
const finalOpacity = interpolate(
frame,
[highlightStart, highlightStart + 8],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
{/* Typing layer */}
<div style={{ opacity: typedOpacity }}>{typedText}</div>;
{/* Final layer with highlight */}
<div style={{ position: "absolute", inset: 0, opacity: finalOpacity }}>
<span>{preText}</span>
<span style={{ backgroundColor: COLOR_HIGHLIGHT }}>{HIGHLIGHT_WORD}</span>
<span>{postText}</span>
</div>;
注意两个透明度曲线在时间轴上的设计:打字层从 highlightStart - 8 帧开始、经过 8 帧到 highlightStart + 8 帧内从 1 降到 0(渐隐窗口共 16 帧);最终层则从 highlightStart 帧起用 8 帧从 0 升到 1。两层渐隐与渐显在中间相互重叠,因此观众看到的是高亮"柔和浮现",而不是某帧突然变色。position: "absolute" 加上 inset: 0 让最终层完全叠在打字层上方,两层文字必须完全同位置才不会在过渡中出现重影漂移。
对"双层必须像素级对齐"这一点,仓库的完整实现给出了非常具体的工程细节。typewriter-highlight.ts 中,打字层与最终高亮层共享了完全一致的排版常量:
const FONT_SIZE = Math.max(56, Math.round(width * 0.075)); // 随画布宽度响应式
const FONT_WEIGHT = 800;
const LINE_HEIGHT = 1.05;
const LETTER_SPACING = -0.6;
两层均设置 whiteSpace: "pre"、相同的 fontSize/fontWeight/lineHeight/letterSpacing——其中 whiteSpace: "pre" 尤为重要,它保证空格宽度不会被折叠,否则两层文本在含空格时会错位。另外,两层文字要放在同一个 position: "relative" 的外层容器中,由外层统一控制整体的入场位移与透明度(示例中外层容器用 spring() 驱动入场,做了一次从 translateX(18px) 到 0 的滑入),避免两层各自运动导致错位。
高亮层内部还有一处加分细节:为了切出"高亮词"的前后文,示例先用 FULL_TEXT.indexOf(HIGHLIGHT_WORD) 定位关键词下标,再通过 slice 切出 preText 与 postText,把高亮词夹在中间:
const highlightWordIndex = FULL_TEXT.indexOf(HIGHLIGHT_WORD);
const preText = hasHighlight ? FULL_TEXT.slice(0, highlightWordIndex) : "";
const postText = hasHighlight
? FULL_TEXT.slice(highlightWordIndex + HIGHLIGHT_WORD.length)
: "";
而真正的高亮色块并不直接给文字加 backgroundColor,而是渲染为一个独立的绝对定位 <span>:它在垂直方向居中、宽高略超出文字(left/right: -0.12em、height: 1.05em)、带 borderRadius: 0.2em,并通过 spring() 驱动其 scaleX 从 0 展开到 1、transformOrigin: "left center",让黄色底色像从左向右"刷"过单词(示例中的 COLOR_HIGHLIGHT = "#FFE44D"),文字本体则用 zIndex: 1 压在色块之上。这样最终效果就同时具备"双层淡化换层"与"高亮块从左向右生长"的双重质感。
时间轴编排:把三个子效果串成一段完整动画
掌握了打字机切片、平滑光标和双层高亮这三个独立技巧后,把它们编排进同一条时间轴需要一次统一的帧号规划。typewriter-highlight 示例的时间轴是一个很好的参考:
const ENTRANCE_DURATION = 22; // spring 入场
const typeEndFrame = FULL_TEXT.length * CHAR_FRAMES; // 打字完成帧
const HIGHLIGHT_DELAY = 10; // 打完后的停顿
const highlightStart = typeEndFrame + HIGHLIGHT_DELAY; // 高亮开始帧
时间线可以被拆成清晰的四段:前 22 帧整体入场滑动 → 0 ~ typeEndFrame 逐字打字(每 3 帧一字)→ typeEndFrame ~ highlightStart 光标停闪停顿 → highlightStart 起触发双层交叉淡化与高亮块 scaleX 展开。其中高亮块展开用的是带弹性的 spring() 配置:
const highlightProgress = spring({
fps,
frame: frame - highlightStart,
config: { damping: 22, stiffness: 180, mass: 0.9 },
durationInFrames: HIGHLIGHT_SPRING_DURATION,
});
注意这里通过 frame: frame - highlightStart 把"全局帧号"转换成"相对高亮起始点的局部帧号",这是 Remotion 中组合多个顺次动画的常用手法,等价于在一个局部的时间原点内计算进度。所有子效果共享同一个 fps(来自 useVideoConfig()),确保在任何帧率下节奏一致。
如何验证与运行
typography 技能对应的 typewriter-highlight 与 word-carousel 示例都属于模板的 "Text" 分类(见 typewriter-highlight.ts 中的 category: "Text"、durationInFrames 与 fps 元数据),它们被编译进 Remotion 预览,可以直接当作可运行参考。若要亲手复现这些动画,可进入模板目录执行:
npm install
npm run dev
启动后即可打开模板的交互界面,在提示词输入框中粘贴内置的 "Typewriter text" 示例提示(位于 prompts.ts),或直接渲染成品视频:
npx remotion render
升级 Remotion 运行时可用 npx remotion upgrade。观察输出时建议重点核对三个验收点:打字过程中光标是否始终贴合最后一个字符、光标闪烁是否平滑无硬闪、关键词高亮出现瞬间画面是否发生布局抖动或重影——这三点恰好分别对应技能文档中字符串切片、平滑插值、稳定宽度容器与双层交叉淡化四类方案要解决的痛点。
小结
这份排版技能文档虽然篇幅精炼,却浓缩了 Remotion 文字动画中最容易踩坑的四条规律,可以提炼为一组可迁移的编写守则:
- 打字机 = 字符串切片:让 DOM 只包含已输出的字符,光标随文本自然生长,杜绝逐字 opacity 造成的布局与光标定位问题;
- 闪烁 = 周期相位上的平滑插值:用
frame % 周期长度配合interpolate(..., [1, 0, 1], clamp)产生柔和呼吸感,替代硬性的奇偶开关; - 换词 = 最长词撑宽 + 绝对定位叠层:
visibility: "hidden"的占位层固定容器宽度,正在展示的词在绝对定位层中自由更替,需要过渡时再做双层交叉淡化与 blur; - 高亮 = 双层交叉淡化:打字层与高亮最终层共享完全一致的字体、行高、字距与
whiteSpace设置,用时间上相互重叠的渐隐/渐显完成无突变的换层。
如果你希望在 AI 生成工作流中复用这套知识,可以直接查阅并引用 typography.md;需要完整、可编译的代码样板时,则建议对照 typewriter-highlight.ts、word-carousel.ts 与 text-rotation.ts 三份示例源码,它们的命名与节奏常量都经过打磨,是实践上述全部模式的最直接参照。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00