Text Morph:让一个词「变形」成另一个词
「十二个月」不断切换的那种标题动画,看久了会发现它和普通的词轮播不一样:字不是整块换掉的。旧词的每个字母各自滑向新位置、在半路换成另一张脸,长出来的字母从串尾外侧挤进来——过渡中途甚至能看到字距被短暂拉开、又收拢回去。这一节把它拆成三步做出来:量(每个字在哪)、排(谁淡出、谁淡入、谁只是搬家)、错(每个字什么时候起跑)。三步里
JS 只做前两步,真正的插值一行 transition 就够了。
同一个「末尾那个词在转」的需求,本系列已经有一条零 JS 的路子(@counter-style 当词表 + @property <integer> 当索引)。那条路整词整块地换,胜在完全不用 JS;这一页要的是字级别的连续性,代价就是必须测量,也就必须有
JS。想看同一效果由手写引擎逐帧驱动、并能 seek / 反向 / 中途重定向的版本,见动画引擎系列的 Text Morph。
1 · 先看三种做法差在哪
同一组词、同一个节奏,三行分别是:直接换 textContent、整词 crossfade、逐字 morph。
-
① 换
textContent——没有中间态。视觉上是「闪一下变成另一个词」,读者的眼睛得重新找一遍焦点。 - ② 整词 crossfade——有中间态,但那是两个各自居中的完整词叠在一起。重影里读不出「谁变成了谁」,长度差得越多越糊。
- ③ 逐字 morph——每个字是一个独立的动画主体。眼睛能跟住某个字母从旧位置挪到新位置,于是「这是同一行字在变形」这件事被讲清楚了。
本质区别在动画主体的粒度。①的主体是「文本内容」这个不可插值的离散值;②的主体是整个词;③把主体降到单个字,于是每个字都能有自己的起点、终点和时间表——这正是 split-text 那套思路推到极致的样子。
2 · 第一步:量——为什么这套效果离不开 JS
要让每个字各自搬家,就得知道每个字该在哪。而「第 i 个字距串首多远」只有排版引擎知道(字宽随字体、字号、字重变),CSS 拿不到这个数。所以先拿一把尺子逐字量:
// 一把与舞台同字体同字号的隐藏尺子, 逐字量出左缘偏移与宽度
const spans = [...text].map((ch) => { /* 一个字一个 span, 塞进 ruler */ });
const base = ruler.getBoundingClientRect().left;
const glyphs = spans.map((s, i) => {
const r = s.getBoundingClientRect();
return { ch: text[i], x: r.left - base, w: r.width };
});
为什么不用 canvas.measureText? 因为最终显示就是逐字 <span>,而逐字 span 会切断字距调整(kerning)与连字(ligature)。用同一种排布去量,量出来的位置才与显示自洽;若改用整串 measureText,量得的位置带
kerning、显示时却没有,字会始终「差一点」。同理,尺子必须与舞台同字体同字号,所以它就挂在舞台里面,只是 visibility: hidden。web font 落地前后字宽还会再变一次,document.fonts.ready 之后要重量。
量完把坐标中心化:每个字的 x 减去整串宽度的一半,换成「相对文本中心」的偏移。这一步看着琐碎,却是整套算法的基准——两串长度不同,只有以中心为原点,共有的字才落在可比的位置上。落到 CSS 上也刚好省一件事:
.morph-char {
position: absolute; left: 50%; top: 0; /* 原点 = 容器中心 */
transform: translateX(var(--x)); /* --x 是相对中心的偏移 */
}
别让容器宽度也过渡。 直觉做法是让舞台宽度从旧串宽过渡到新串宽、内部靠
text-align: center 居中。但那样每个字的实际位置会同时受两条时间轴摆布——容器那条(单一时长)和自己那条(各有交错),两者一打架,字就会在过渡中途莫名其妙地整体漂移。以中心为原点、容器不参与动画,各字才真正独立。
3 · 第二步:排——两串字怎么配对
有了两串的坐标,接下来要回答:新画面上的每个字,是从旧画面的哪个字变过来的?两种策略,代价与观感各不相同。
index 对齐:第 i 个对第 i 个。字相同就只是搬家(keep),字不同就在同一段轨迹上叠一对 out +
in(旧的淡出、新的淡入,同起同止)——这就是「原地换脸」。新串更长时,多出来的字在旧串里没有位置,让它从旧串末尾继续往外排的地方滑进来,比原地淡入更像「这串字被撑开了」。
LCS 对齐:先用最长公共子序列找出两串里真正共有的字,这些字复用为 keep、长途搬家到新位置,其余淡出 / 淡入。同一个字母不会被毫无理由地换成另一个字母,过渡更「讲道理」,但字动得更多、路线更长。这正是列表 diff
的内核用在了字符上。
下面这一格把过渡定格了:拖播放头看任意时刻的画面,下面的槽位表同步显示每个字的 role、起止坐标、起跑时间。换个对齐策略,看同一对词被排成完全不同的一组槽位。
这一格是怎么定格的? 不是 JS 逐帧算位置,而是把同一段过渡改写成 @keyframes、用 animation-play-state: paused 冻住,再用负的 animation-delay 把播放头挪到任意时刻——纯 CSS 的逐帧检视,缓动曲线与真实播放完全一致。
.morph-scrub .morph-char {
animation: morph-x var(--dur) var(--ease) both paused,
morph-o var(--fade) linear both paused;
animation-delay: calc(var(--d) - var(--t)), calc(var(--d) - var(--t));
}
@keyframes morph-x { from { transform: translateX(var(--x0)) } to { transform: translateX(var(--x1)) } }
both 让播放头落在区间外时停在首帧或末帧,于是「还没轮到我起跑」和「我早跑完了」都被正确表达;每个字的 delay 各减去自己的 --d,交错便原封不动地保留在定格画面里。同一套变量,换个驱动方式就从「播放」变成「检视」。
@keyframes 里可以 var(--x0) 引用元素上的自定义属性——被插值的是 transform,--x0 只是取值,全程不变。这与 Word Rotator 那页的情形正相反:那里要动的就是 --i 本身,所以非得
@property 注册成 <integer> 才能插值。一句话:要过渡的属性是 transform,不是那个变量,所以这里的 --x 完全不必注册。
4 · 第三步:错——招牌的「字距被拉开」
如果所有字同时起跑、同时落定,效果会意外地平淡:整行字像一块板子在变形。真正让它好看的是交错(stagger)——同一段过渡,第 i 个字晚一点开始:
.morph-char {
transition:
transform var(--dur) var(--ease) var(--d), /* --d 是这个字的起跑时间 */
opacity var(--fade) linear var(--d);
}
一个 transition-delay 而已,但过渡中途每个字走到的进度不同,字距就被短暂拉开、再收拢回去——视频里那些「Janua ry」「Oc to ber」的瞬间全部来自这里。方向决定波从哪儿发起:ltr 从左推过去最像「读」的顺序,center 从中间向两边扩散最像「撑开」,random
则有一种老式翻牌机的躁动。
交错的强度要跟着字数走。each 固定时,字越多总时长越长(末字的起跑时间 =
),十几个字母的长月份会明显拖沓。工程上常见两种收法:给总交错量封顶(each = min(each, maxSpread / n)),或者干脆按「到发起点的距离归一化后再乘一个总量」,让长短词的总时长一致。本页的 lab 用的是前者的反面——不封顶,正好让你把 each 拉大后亲眼看到它失控的样子。
纯 CSS 也能表达这类「第 i 个晚一点」,见本系列 Scrubbable Stagger 用 sibling-index() 直接在 CSS 里算出每个兄弟的延迟。这里没用它,是因为槽位本来就是 JS 现算现建的,顺手写上 --d 更直接;但若你的字是静态写死在 HTML 里的,sibling-index()
能让这一步也脱离 JS。
5 · 完整轮播
三步凑齐,就是开头那个效果。所有参数都可调——词表、对齐策略、位移时长、换脸时长、交错强度与方向、缓动、停留时长、字号。下面的代码面板是当前这一轮真正生效的 CSS 与变量值。
几个调参手感:
| 参数 | 调大会怎样 | 一般取值 |
|---|---|---|
dur(位移) |
字飘得久,整体变「软」;过大会让人等 | 400–700ms |
fade(换脸) |
比 dur 短才干脆;等于 dur 时重影期太长,画面发糊 |
约 dur 的一半 |
each(交错) |
字距拉得越开、越有「撑开」感;过大整行就散了 | 20–45ms |
hold(停留) |
落定后的静止时间。太短会显得焦躁,读者来不及读完 | ≥ 1.2s |
ease |
overshoot 让字轻微冲过头再回来,配长 hold 很讨喜;linear 则死板 |
收尾慢的曲线 |
为什么 transition 就够了,不必上 WAAPI。 这里每次切词都要重建 DOM、重写变量,看上去和 element.animate() 那套「反正都要 JS」的场景很像。但两者要的东西不同:文字扫光那页需要间歇、离屏暂停、精确 cancel,所以拿了 WAAPI 的 Animation 句柄;这里只需要「从当前值过渡到新值」,而这正是 transition 的定义——改属性即过渡,没有句柄要管,被打断时浏览器自动从当前中间值接着走。真要 seek / 反向 / 在飞行中改目标,那就该换成引擎逐帧驱动了(见引擎版)。
两帧节奏是唯一的技巧。 新字必须先以起始样式进入文档、被浏览器认领,下一步再改成目标样式,transition 才知道「从哪儿来」。同一帧内又建节点又改样式,浏览器只看得见最终值,什么都不会过渡。所以中间要强制读一次布局:
host.append(...els); // 1. 以起始 --x / --o 入场
void host.offsetWidth; // 2. 读一次布局, 逼浏览器认领起始样式
els.forEach(...) // 3. 改成目标 --x / --o, transition 接管
这次强制重排是故意的,和性能那页要避免的 layout thrashing 不是一回事——那里说的是一帧内读写交错反复触发,这里是每次切词只读一次。
6 · 无障碍与收尾
屏幕阅读器读到的应该是一个词,不是一串字母。 拆成逐字 <span> 后,读屏软件很可能把「September」念成「S-e-p-t-e-m-b-e-r」。做法是给每个字 aria-hidden="true",另放一个视觉隐藏的元素承载完整文本:
<span class="morph-sr">September</span> <!-- 只给读屏, clip-path: inset(50%) -->
<span class="morph-char" aria-hidden="true">S</span>…
若这个词会自动轮播,还要考虑给容器 aria-live="off"(别打断用户)或干脆把轮播词换成静态文案——自动变化的文字对读屏和认知障碍用户都不友好。
降级不是关掉动画,而是去掉前庭负担大的部分(见尊重「减少动态效果」)。命中 prefers-reduced-motion: reduce 时,本页把位移与交错都归零、只留一次很短的换脸——信息还在,运动没了。CSS 侧也留了一道兜底:
@media (prefers-reduced-motion: reduce) {
.morph-char { transition-duration: 1ms, 1ms; }
}
性能上心里有数。 每次切词都要重建 n 个绝对定位元素并让它们各跑一条 transform + opacity 过渡。这两个属性都在合成器线程上跑,几十个字毫无压力;但如果你想拿它做整段正文的变形,节点数会瞬间上千——那时该换的不是动画技术,而是别做这个效果。另外
will-change: transform, opacity 只在真正会动的字上加,切完就重建、不会长期挂着。