文字变形:一个词逐字长成另一个词
「十二个月」不断切换的那种标题动画,字不是整块换掉的:每个字母各自滑向新位置、在半路换成 另一张脸,长出来的字母从串尾外侧挤进来。文本动画那页把一句话 拆开做入场,这里更进一步——两串字之间要先对齐:谁只是搬家 (keep)、谁淡出 (out)、 谁淡入 (in)。对齐排出一组槽位后,剩下的又全是老朋友:一段 插值、一条缓动、 一把交错的延迟。
整页只有一个 Animation 在跑,它的值就是「当前是第几毫秒」;每个字的位置与
不透明度都由这一个数经纯函数算出。于是 seek / 反向 / 变速全部免费——拖播放头能停在任意
中间帧,倒放能看着 September 缩回 August。CSS 侧的同一效果 (由 transition 驱动、
每字一条 transition-delay) 见
CSS Animation · Text Morph,
两页共用同一套对齐算法,差别只在「值由谁算、落到哪」。
一个时钟,一串纯函数
点词切换,或直接拖播放头把这次 morph 停在任意时刻。下方甘特图一条 lane 是一个字,
色块是它的 [delay, delay + dur] 区间——红竖线扫过谁,谁就在动。
把 each 拉大,色块阶梯错得越开,过渡中字距被拉开得越夸张:那些
「Janua ry」「Oc to ber」的瞬间全部来自这里。为看清角色,页内把淡出的字标红、
淡入的字标紫,只是搬家的字 (keep) 保持墨色——真实使用时它们当然是同一个颜色。
Animation (那正是
stagger 的常规做法),但那样「整段 morph 现在走到哪」就散在
十几个播放器里,想统一 seek 得逐个 seek。这里换个切法:唯一的 Animation 的值
就是毫秒数,每个字的 delay 只是 render(t) 里的一次减法
(p = (t − delay) / dur)。交错从「一堆播放器的起跑时间」降级成一个纯函数的参数,
于是 seek / 反向 / 变速这些播放控制只需作用在那一个游标上。
render 是 t → 画面 的纯函数,倒放只是让 t 往回走。
淡出的字会重新亮起、淡入的字缩回去,全程不需要任何「撤销」逻辑。对照 CSS 版:
transition 没有播放头可倒,要看中间帧得靠
animation-play-state: paused 配负的 animation-delay 硬凑。
中断:一次 morph 还没播完,词又变了
真实场景里没人等你播完——用户连点、数据连续刷新。此时新的一次 morph 该从哪儿起步? 左边按算法给的起点 (旧串的静止位置) 重新开始:字会瞬间跳回去,过渡断成两截。 右边先读出每个字此刻真正渲染在哪、多透明,拿它当新一段的起点,画面连续。 点「⚡ 连点 3 次」看得最清楚。
✗ 按算法起点重来
✓ 接住当前渲染状态
transition 的语义就是「从当前计算值过渡到新值」——
半路改目标,浏览器自动从中间值接着走,不需要一行代码。引擎版把插值权拿回自己手里,
这份「接住」就得自己实现:每帧记下渲染值,新一段开始时用它覆盖 slot.from。
反过来,CSS 版拿不到播放头、没法 seek 与反向,那是引擎版白送的。两边各自免费的东西,
恰好是对方要手写的东西——这也是选型时真正该问的问题。
animateTo 在飞行中改目标——做法与
飞行中重定向、中断与重定向
两页完全一致,代价是放弃「一个时钟」的可 seek 性 (弹簧的时长是结果而非给定)。
可 seek 与 可无缝打断在这里是一对需要取舍的性质。
对齐用的最长公共子序列就是 diff 的内核,展开见序列对齐 DP
与列表 diff;把同一套「量 → 对齐 → 交错」交给
transition 表达 (以及无障碍、降级、性能上的取舍),见
CSS 侧那一页。