Text that moves: typing, per-character animation, and the terminal rain会动的文字:打字、逐字动画与终端数字雨
Text is the easiest thing to animate badly. The effects that look effortless on this site are all one of three techniques, and picking the wrong one is usually why a text animation feels cheap.文字是最容易被做丑的东西。本站那些看起来毫不费力的效果,全都属于三种技术之一,而选错技术,通常就是文字动效显廉价的原因。
Typing is a substring on a clock, not a hundred timers
The obvious implementation is a timer per character. Do not do that: thirty characters means thirty timers, they drift apart under load, and you cannot pause or speed up the whole thing without touching all of them.
A typewriter is one elapsed-time counter and one slice:
The speed lives in one place, so retiming the animation is a single number. Punctuation pauses become easy too — hold the previous character for longer before advancing:
The cursor is a separate concern and should stay one. Blinking with animation on a pseudo-element costs nothing, while toggling it from JS means a timer that competes with your typing clock and eventually falls out of phase.
The loop version adds a state machine — typing, holding, deleting — and the reason it reads so well is the asymmetric timing. Typing is fast, the hold is long, deleting is faster than typing. Symmetric durations make it feel mechanical.
const text = 'YILANA.CN';
const CPS = 12; // characters per second
let start = performance.now();
function frame(now) {
const n = Math.floor((now - start) / 1000 * CPS);
el.textContent = text.slice(0, n); // one assignment
if (n < text.length) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Per-character animation wants CSS, not JavaScript
The moment each letter moves independently — the wave, the flip — the naive approach is to compute every letter's transform in JS each frame. That is a lot of style writes per frame for something CSS can do on the compositor.
Split the text into spans once, at build time, and give each one a stagger:
The stagger is the whole trick. i * 0.06s means the last letter starts 0.06s × (length − 1) later than the first, so a twelve-letter word takes 0.66s to finish entering. Change the multiplier and the wave gets denser or looser — it is the one number worth playing with.
Once the delays are set, the animation itself is a transform and opacity keyframe. Those two properties are the only ones the compositor can animate without re-running layout, which is why this stays smooth on a phone while a margin-top animation would not.
The flip uses the same stagger with rotateX and a perspective on the container. The perspective has to be on the parent, not the letter, or each letter gets its own vanishing point and the row stops reading as one plane.
.char {
display: inline-block;
animation: rise 0.6s cubic-bezier(.2,.8,.3,1) both;
animation-delay: calc(var(--i) * 0.06s);
}
@keyframes rise {
from { transform: translateY(0.5em); opacity: 0; }
to { transform: none; opacity: 1; }
}
Danmaku: the hard part is cleanup, not motion
Flying text is a translateX from off-screen right to off-screen left. Half a line of CSS. What makes a danmaku system work is not the movement, it is not accumulating garbage:
The onfinish handler is doing the real work. Without it, every message leaves a DOM node behind, and after a few minutes of comments the page is carrying thousands of invisible elements and starts to stutter for reasons that look unrelated.
Two more things separate a demo from something usable. Vertical placement should avoid collisions — track the newest element per lane, and pick the first lane where the previous message has moved far enough left. And the duration should be constant in distance per second, not constant overall, or long messages visibly outrun short ones.
const el = document.createElement('div');
el.className = 'danmaku';
el.textContent = msg;
el.style.top = lane * 34 + 'px';
track.appendChild(el);
el.animate(
[{ transform: 'translateX(100vw)' }, { transform: 'translateX(-100%)' }],
{ duration: 9000, easing: 'linear' }
).onfinish = () => el.remove(); // without this, the DOM grows forever
WAAPI returns an Animation object; onfinish is the reliable hook — transitionend does not fire if the element is hidden or the property never changes.
The matrix rain is a grid of independent columns
The terminal rain looks like particles and is not. It is a grid: one head position per column, each column falling at its own speed, redrawn from scratch every frame on a canvas:
Three decisions give it the look. The fade — painting a translucent black rectangle over everything each frame instead of clearing — leaves the ghost of every previous character, which is what produces the trailing fade. The random character is re-rolled only when the head moves, so the letters hold for a frame and then change. And the head is drawn brighter than the trail, which is the only thing that makes it read as falling rather than as a texture.
The trail length is entirely controlled by the fade alpha. 0.08 gives a long smear, 0.2 a short one. There is no trail array, no history buffer — the canvas itself is the history.
function frame() {
ctx.fillStyle = 'rgba(0,0,0,0.08)'; // the fade IS the trail
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#39ff88';
columns.forEach((c, i) => {
if (Math.random() > 0.96) c.ch = randomChar();
ctx.fillText(c.ch, i * 16, c.y);
c.y += c.speed;
if (c.y > H && Math.random() > 0.975) c.y = 0;
});
requestAnimationFrame(frame);
}
Why setTimeout is the wrong clock for any of this
Every one of these effects can be written with setTimeout, and every one of them will be worse. Timers are throttled in background tabs, they do not align with the frame the browser is about to paint, and they accumulate drift — a chain of 40ms timeouts is not 40ms per step, it is 40ms plus scheduling overhead per step, compounding.
requestAnimationFrame gives you a timestamp you can compute from directly, and it stops when the tab is hidden. For character-by-character CSS animation, do not use a clock at all — let animation-delay do the scheduling, because the compositor is better at it than your loop.
The one place a timer is still right: things that should keep running when the tab is hidden, like a countdown. Animation is not that.
Where to take this
Pick by what moves: if the whole string appears gradually, slice on a clock. If characters move independently, split into spans and stagger with CSS delays. If there are many moving strings, animate transforms and clean up on finish. And if it looks like a texture made of glyphs, it is probably a grid, not particles.
打字是时钟上的一次截取,不是一百个定时器
最直觉的写法是每个字符一个定时器。别这么干:三十个字符就是三十个定时器,负载一高它们就开始各跑各的,而且你没法在不碰全部定时器的前提下暂停或加速。
打字机只需要一个已用时间计数和一次 slice:
速度只存在一个地方,所以改节奏就是改一个数。标点停顿也变得容易——推进之前让上一个字符多停一会儿:
光标是另一件事,也该保持独立。用 animation 让它闪,成本为零;从 JS 里来回切,等于多了一个和你打字时钟抢节拍的定时器,迟早失相。
循环版多了一个状态机——打字、停顿、删除——它之所以读起来舒服,是因为时长不对称。打字快、停顿长、删除比打字更快。三段等长,就会像机器。
const text = 'YILANA.CN';
const CPS = 12; // 每秒字符数
let start = performance.now();
function frame(now) {
const n = Math.floor((now - start) / 1000 * CPS);
el.textContent = text.slice(0, n); // 一次赋值
if (n < text.length) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
逐字动画应该交给 CSS,而不是 JavaScript
一旦每个字母独立运动——波浪、翻转——直觉做法是每帧在 JS 里算出每个字母的 transform。为了合成器一行就能做完的事,每帧写一堆样式,不值。
在构建时把文本切成一个个 span,给每个一个错峰:
错峰就是全部诀窍。i * 0.06s 意味着最后一个字母比第一个晚 0.06s × (长度 − 1) 开始,十二个字母的单词要 0.66 秒入场完毕。改这个乘数,波就变密或变松——这是唯一值得反复调的数值。
延迟定好之后,动画本身只是一个 transform 和 opacity 关键帧。这两个属性是合成器唯一能在不重跑布局的情况下动的,所以它在手机上依然顺,而换成 margin-top 就会卡。
翻转用同一个错峰配 rotateX,外加容器上的 perspective。透视必须放在父元素上,不能放在字母上,否则每个字母各有自己的消失点,这一行就不再像一个平面了。
.char {
display: inline-block;
animation: rise 0.6s cubic-bezier(.2,.8,.3,1) both;
animation-delay: calc(var(--i) * 0.06s);
}
@keyframes rise {
from { transform: translateY(0.5em); opacity: 0; }
to { transform: none; opacity: 1; }
}
弹幕:难的是清理,不是运动
飞过去的文字就是一次 translateX,从右侧屏外到左侧屏外。半行 CSS。真正决定一套弹幕系统能不能用的,不是运动,而是它不堆垃圾:
onfinish 干的才是真活。没有它,每条消息都在 DOM 里留下一个节点,几分钟之后页面上挂着几千个看不见的元素,然后开始莫名其妙地卡。
还有两件事把「demo」和「能用」分开。纵向位置要避让——按轨道记录最新元素,挑第一条「上一条已经飞得够远」的轨道。以及时长应该让速度恒定(每秒多少像素),而不是总时长恒定,否则长消息会明显比短的跑得快。
const el = document.createElement('div');
el.className = 'danmaku';
el.textContent = msg;
el.style.top = lane * 34 + 'px';
track.appendChild(el);
el.animate(
[{ transform: 'translateX(100vw)' }, { transform: 'translateX(-100%)' }],
{ duration: 9000, easing: 'linear' }
).onfinish = () => el.remove(); // 少了这句,DOM 会无限长大
WAAPI 返回 Animation 对象,onfinish 才是可靠钩子——元素被隐藏或属性没变化时,transitionend 根本不会触发。
数字雨是一网格互不相干的列
终端数字雨看着像粒子,其实不是。它是一个网格:每列一个头部位置,各列速度不同,每帧在画布上从头重画:
三个决定给出这个观感。拖影——每帧盖一层半透明黑矩形而不是清空——把每个字符的残影留在那里,尾迹就是这么来的。随机字符只在头部移动时才重摇,所以字母会停一帧再变。头部画得比尾迹亮,这是唯一让它读作「下落」而不是「纹理」的原因。
尾迹长度完全由那层透明度控制。0.08 拖得长,0.2 拖得短。没有尾迹数组,没有历史缓冲——画布本身就是历史。
function frame() {
ctx.fillStyle = 'rgba(0,0,0,0.08)'; // 这层淡出就是尾迹
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#39ff88';
columns.forEach((c, i) => {
if (Math.random() > 0.96) c.ch = randomChar();
ctx.fillText(c.ch, i * 16, c.y);
c.y += c.speed;
if (c.y > H && Math.random() > 0.975) c.y = 0;
});
requestAnimationFrame(frame);
}
为什么 setTimeout 是这些效果里错误的时钟
上面每一个效果都能用 setTimeout 写,而且每一个都会更差。定时器在后台标签页会被限流,它不和你即将绘制的那一帧对齐,而且会累积漂移——一串 40ms 的定时器不是每步 40ms,而是每步 40ms 加上调度开销,越滚越远。
requestAnimationFrame 给你的时间戳可以直接拿来算,标签页隐藏时它还会停。至于逐字的 CSS 动画,干脆别用时钟——交给 animation-delay 去排,合成器比你自己的循环更擅长这件事。
定时器唯一还正确的地方:那些应该在标签页隐藏时继续跑的东西,比如倒计时。动画不属于这一类。
接下来可以怎么用
按「什么在动」来选:整句话逐渐出现,就在时钟上截取;字符各自运动,就切 span 用 CSS 延迟错峰;有很多条会动的字符串,就只动 transform 并在结束时清理。如果它看起来像一片由字形构成的纹理,那多半是一个网格,不是粒子。
Related demos相关示例
Each one runs on its own page, with the source shown below the stage.每个都在独立页面实时运行,舞台下方附完整源码。
Every effect on this site is a single self-contained file. Copy it, change one number, see what happens.本站每个特效都是单个自包含文件。复制走,改一个数字,看会发生什么。
Browse all 131 effects浏览全部 131 个特效