首页
/ Remotion 文字排版动画实战:打字机、光标闪烁、词轮播与双层高亮全解

Remotion 文字排版动画实战:打字机、光标闪烁、词轮播与双层高亮全解

2026-09-07 19:39:48作者:柯茵沙

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-highlightexample-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 解耦、稳定可预测。值得注意的两个细节:

  1. Math.min()typedChars 上限定在 FULL_TEXT.length,避免切片越界;
  2. 引入布尔量 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. 旧词透明度从 1 渐隐到 0(outOpacity),新词从 0 渐显到 1(inOpacity),两词用 position: "absolute" 叠在同一位置做交叉淡化;
  2. 配合模糊滤镜(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 切出 preTextpostText,把高亮词夹在中间:

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.12emheight: 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-highlightword-carousel 示例都属于模板的 "Text" 分类(见 typewriter-highlight.ts 中的 category: "Text"durationInFramesfps 元数据),它们被编译进 Remotion 预览,可以直接当作可运行参考。若要亲手复现这些动画,可进入模板目录执行:

npm install
npm run dev

启动后即可打开模板的交互界面,在提示词输入框中粘贴内置的 "Typewriter text" 示例提示(位于 prompts.ts),或直接渲染成品视频:

npx remotion render

升级 Remotion 运行时可用 npx remotion upgrade。观察输出时建议重点核对三个验收点:打字过程中光标是否始终贴合最后一个字符、光标闪烁是否平滑无硬闪、关键词高亮出现瞬间画面是否发生布局抖动或重影——这三点恰好分别对应技能文档中字符串切片、平滑插值、稳定宽度容器与双层交叉淡化四类方案要解决的痛点。

小结

这份排版技能文档虽然篇幅精炼,却浓缩了 Remotion 文字动画中最容易踩坑的四条规律,可以提炼为一组可迁移的编写守则:

  1. 打字机 = 字符串切片:让 DOM 只包含已输出的字符,光标随文本自然生长,杜绝逐字 opacity 造成的布局与光标定位问题;
  2. 闪烁 = 周期相位上的平滑插值:用 frame % 周期长度 配合 interpolate(..., [1, 0, 1], clamp) 产生柔和呼吸感,替代硬性的奇偶开关;
  3. 换词 = 最长词撑宽 + 绝对定位叠层visibility: "hidden" 的占位层固定容器宽度,正在展示的词在绝对定位层中自由更替,需要过渡时再做双层交叉淡化与 blur;
  4. 高亮 = 双层交叉淡化:打字层与高亮最终层共享完全一致的字体、行高、字距与 whiteSpace 设置,用时间上相互重叠的渐隐/渐显完成无突变的换层。

如果你希望在 AI 生成工作流中复用这套知识,可以直接查阅并引用 typography.md;需要完整、可编译的代码样板时,则建议对照 typewriter-highlight.tsword-carousel.tstext-rotation.ts 三份示例源码,它们的命名与节奏常量都经过打磨,是实践上述全部模式的最直接参照。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391