文字变形:一个词逐字长成另一个词
标题里那种不断切换的月份动画,字不是整块换掉的:每个字母各自滑向新位置、在半路换成另一张脸,长出来的字母从串尾外侧挤进来。文本动画那页把一句话拆开做入场,本页更进一步——两串字之间要先对齐:谁只是搬家(keep)、谁淡出(out)、谁淡入(in)。
对齐排出一组槽位之后,剩下的全是老朋友:一段插值、一条缓动、一把交错的延迟。
1 · 唯一时钟与纯函数的 render
整页只有一个 Animation 在跑,它的值就是「当前是第几毫秒」。每个字的位置与不透明度都由这一个数经纯函数算出:
十几个字大可各建一个 Animation,那正是 stagger 的常规做法。但那样「整段 morph 现在走到哪」就散在十几个播放器里,想统一 seek 得逐个 seek。换成一个时钟之后,交错从「一堆播放器的起跑时间」降级成一个纯函数的参数——delay 只是 render(t) 里的一次减法。
页内把淡出的字标红、淡入的字标紫,只是搬家的字保持墨色;真实使用时它们当然是同一个颜色。把 each 拉大,色块阶梯错得越开,过渡中字距被拉开得越夸张——那些「Janua ry」「Oc to ber」的瞬间全部由此而来。
注 · 首帧必须等字体就位。字宽由一把隐藏的 ruler 量出来,回退字体与真正的显示字体宽度不同,document.fonts.ready 之前量到的偏移全是错的——表现为页面刚加载时整串字位置突然抖一下。两个 lab 都把首次 build 挂在 fonts.ready 之后。
2 · 未播完即被打断的起点
真实场景里没人等播完——用户连点、数据连续刷新。此时新的一次 morph 该从哪儿起步?
按算法给的起点(旧串的静止位置)重新开始,字会瞬间跳回去,过渡断成两截。正确的做法是先读出每个字此刻真正渲染在哪、多透明,拿它当新一段的起点。实现上只需每帧把渲染值记进一个 live 数组,新一段开始时按槽位序覆盖 slot.from。
警示 · 这件事 CSS 版是白送的。transition 的语义就是「从当前计算值过渡到新值」,半路改目标浏览器自动从中间值接着走,不需要一行代码。引擎版把插值权拿回自己手里,这份「接住」就得自己实现。反过来,CSS 版拿不到播放头、没法 seek
与反向,那是引擎版白送的。两边各自免费的东西,恰好是对方要手写的东西。
注 · 本页的「接住」只对齐了位置,速度仍从新缓动的起点重新算——默认 expoOut 起步最快,所以观感尚可。若要连速度也连续,得把每个字的
换成弹簧值源、在飞行中改目标,做法见飞行中重定向与中断与重定向。代价是放弃「一个时钟」的可 seek 性——弹簧的时长是结果而非给定。可 seek 与可无缝打断因而是一对需要取舍的性质。
import { Animation } from '@vega/anim';
import { planMorph, slotDelays } from './text-morph.ts';
// 1. 对齐: 两串字 → 一组槽位 { role, from, to, order }
const slots = planMorph(oldGlyphs, newGlyphs, 'index');
const delays = slotDelays(slots, each, 'ltr'); // 每个槽位晚多久起跑
const total = Math.max(...delays) + Math.max(dur, fade);
// 2. render 是纯函数: 给定 t, 每个字的位置与不透明度唯一确定
const render = (t) => slots.forEach((s, i) => {
const q = ease(clamp01((t - delays[i]) / dur)); // 位移进度
const f = clamp01((t - delays[i]) / fade); // 换脸进度
el[i].style.transform = `translateX(${s.from + (s.to - s.from) * q}px)`;
el[i].style.opacity = s.role === 'keep' ? 1 : s.role === 'in' ? f : 1 - f;
});
// 3. 唯一的播放器: 它的「值」就是毫秒数
const clock = new Animation({
keyframes: [0, total], duration: total, ease: 'linear',
onUpdate: render,
});
clock.seek(320); clock.reverse(); clock.speed = 0.25;
对齐用的最长公共子序列就是 diff 的内核,展开见序列对齐 DP与列表 diff。把同一套「量、对齐、交错」交给 transition 表达,以及随之而来的无障碍与降级取舍,见
CSS 侧那一页。