When text stops being text当文字不再是文字
There is a point where typography turns into an image. The trick is knowing which side of that line you are on — because the sampling approach and the CSS approach optimise for completely different things.排版走到某一步就变成了图像。诀窍是判断自己站在线的哪一边——因为采样和 CSS 这两条路,优化的东西完全不同。
Sample the glyph, then treat the samples as particles
The particle text effect looks like it needs a font parser. It needs getImageData. Draw the string to an offscreen canvas, read the pixels back, and keep the coordinates where the alpha is above a threshold:
The step size is the whole design decision. step = 4 means you sample every fourth pixel, giving a few thousand points for a large heading. step = 2 quadruples the count and the cost. This is also why the effect works better at large font sizes — at 12px, sampling every fourth pixel destroys the letterform entirely.
Once you have the points, the animation is trivial: each particle starts somewhere random with a velocity, and the target is its sampled position. Add a spring toward the target and it assembles. That is the assemble demo, and it is genuinely just a lerp:
The other version inverts it — the text is already there, and clicking fires particles outward from the click point. Same sample set, opposite direction. Both are under fifty lines once the sampler exists.
function samplePoints(text, font, step) {
const c = document.createElement('canvas');
const x = c.getContext('2d');
x.font = font;
const w = Math.ceil(x.measureText(text).width);
c.width = w; c.height = parseInt(font.match(/(\d+)px/)[1], 10) * 1.4;
x.font = font;
x.fillStyle = '#fff';
x.textBaseline = 'middle';
x.fillText(text, 0, c.height / 2);
const data = x.getImageData(0, 0, c.width, c.height).data;
const pts = [];
for (let y = 0; y < c.height; y += step) {
for (let px = 0; px < c.width; px += step) {
if (data[(y * c.width + px) * 4 + 3] > 128) pts.push([px, y]);
}
}
return pts;
}
measureText must be called with the font already set, or you get the width of the previous font. This is the single most common bug in this effect.
The cheap CSS version: clip the background to the glyphs
If you only want colour inside the letters — the flowing gradient — you do not need canvases at all. Paint the gradient on the element's background, then clip it to the text:
The two properties exist in both prefixed and unprefixed forms, and the prefixed ones are still required in most browsers. The color: transparent is not optional: without it the text is painted over the clipped background and you see flat black.
Animating the gradient means animating background-position, which is a paint-level property — it will not be composited. For a heading that is fine. For a large area you would instead animate a transform on a pseudo-element that carries the gradient, because transform is composited and background-position is not.
The stroke-to-fill demo uses the sibling trick: -webkit-text-stroke outlines the glyph, and animating the stroke colour toward the fill colour makes the letters appear to flood.
.gradient-text {
background: linear-gradient(90deg, #ff6b9d, #4ecdc4, #ffe66d, #ff6b9d);
background-size: 300% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent; /* without this, black text wins */
animation: slide 6s linear infinite;
}
@keyframes slide {
to { background-position: 300% 0; }
}
Blur is a focus pull, and the eye reads it as depth
The blur/focus demo animates one property and gets three effects for free: the text appears to come from behind, from out of focus, and from a light source, all at once.
Why it works: your visual system uses sharpness as a distance cue. Animating blur to zero is the same signal as something moving into the plane of focus, so the brain fills in the missing depth even though the text never changes size much.
The cost is real. filter: blur() on text forces a repaint every frame, and unlike transform it cannot be handed to the compositor. Keep the animated element small — a single word is fine, a whole paragraph will drop frames on a mid-range phone.
The pulse demo is the same idea with text-shadow instead of blur, plus a slow scale. The scale is what separates it from a plain opacity fade: a breathing object reads as alive, a fading one reads as an error message.
.focus-word {
animation: focus 4s ease-in-out infinite;
}
@keyframes focus {
0%, 100% { filter: blur(6px); opacity: .55; letter-spacing: .14em; }
45%, 60% { filter: blur(0); opacity: 1; letter-spacing: .04em; }
}
Letter-spacing is animated alongside blur on purpose — the two together read as "coming closer", while blur alone reads as "loading".
The reflection: flip it, fade it, and stop it at the right height
The water reflection is a pseudo-element with a vertical flip, a gradient mask, and a small vertical offset:
The mask is what sells it. A flipped copy alone looks like a duplicate; a flipped copy that fades out by 40% of its height looks like a surface. mask-image is doing the work that used to require a second canvas with a gradient destination-in pass.
The offset matters more than it seems. Set it to zero and the reflection touches the text, which reads as a printing error. A few pixels of gap, with the reflection slightly squashed (scaleY(0.9)), is what makes it read as a surface at an angle.
.mirror { position: relative; }
.mirror::after {
content: attr(data-text);
position: absolute;
left: 0; top: 100%;
transform: scaleY(-1) scaleX(1.02); /* flip, slight horizontal stretch */
transform-origin: top;
opacity: .35;
-webkit-mask-image: linear-gradient(to bottom, #000, transparent 60%);
mask-image: linear-gradient(to bottom, #000, transparent 60%);
pointer-events: none;
}
Which of the two to reach for
The sampling approach wins when the text needs to do something no glyph can: explode, assemble, react to a pointer, exist as ten thousand independent things. Its costs are that it is a bitmap — it does not reflow, it does not respond to font-size changes without resampling, and it is invisible to screen readers unless you keep a real text node behind it.
The CSS approach wins when the point is the typography itself: colour, weight, blur, reflection. It stays selectable, searchable, accessible, and it responds to the user's font settings. The costs are the paint-bound animations and the vendor prefixes.
The accessibility point is the one people skip. Both canvas demos on this site keep the real string in the DOM, visually hidden but readable by assistive technology, because a page whose only heading is a canvas is a page with no heading at all.
Where to take this
Break the letterform only when the effect needs it. Sampling gives you particles and costs you text; CSS gives you colour and depth and keeps the text intact. When in doubt, CSS first — and always leave the real string in the DOM.
先把字形采样,再把这些采样点当粒子用
粒子文字看起来需要一个字体解析器。它需要的是 getImageData。把字符串画到离屏画布上,把像素读回来,留下 alpha 超过阈值的那些坐标:
步长是这里全部的设计决定。step = 4 表示每四个像素取一个,一个大标题能得到几千个点。step = 2 点数翻四倍,成本也翻四倍。这也是为什么这个效果在大字号下才好看——12px 的字,每四个像素取一个,字形直接碎没了。
有了点,动画就变简单了:每个粒子从某处随机位置出发、带一个速度,目标是它被采样到的位置。朝目标加一根弹簧,它就聚起来。这就是聚合那个示例,说穿了就是一个插值:
另一个版本反过来——文字本来就在,点击时从点击点向外发射粒子。同一套采样,方向相反。只要采样器在手,两个都不超过五十行。
function samplePoints(text, font, step) {
const c = document.createElement('canvas');
const x = c.getContext('2d');
x.font = font;
const w = Math.ceil(x.measureText(text).width);
c.width = w; c.height = parseInt(font.match(/(\d+)px/)[1], 10) * 1.4;
x.font = font;
x.fillStyle = '#fff';
x.textBaseline = 'middle';
x.fillText(text, 0, c.height / 2);
const data = x.getImageData(0, 0, c.width, c.height).data;
const pts = [];
for (let y = 0; y < c.height; y += step) {
for (let px = 0; px < c.width; px += step) {
if (data[(y * c.width + px) * 4 + 3] > 128) pts.push([px, y]);
}
}
return pts;
}
measureText 必须在 font 已经设好之后调用,否则量到的是上一个字体的宽度。这是这个效果里最常见的 bug。
便宜的那条路:把背景裁到字形里
如果你只是想要颜色跑到字里面——流光渐变字——根本不用碰 canvas。把渐变画在元素的背景上,再裁到文字:
这两个属性都有带前缀和不带前缀的两种写法,而多数浏览器目前仍然认带前缀的。那句 color: transparent 不是可选的:不给它,文字会盖在裁好的背景上面,你看到的是一片纯黑。
让渐变动起来等于让 background-position 动,而它是绘制层属性,不会被合成。一个标题这样没问题。如果面积大,正确做法是在伪元素上放渐变、只动那个伪元素的 transform,因为 transform 走合成,background-position 不走。
描边填充用的是隔壁那招:-webkit-text-stroke 勾出字形,再把描边颜色往填充色过渡,字看起来就被「灌满」了。
.gradient-text {
background: linear-gradient(90deg, #ff6b9d, #4ecdc4, #ffe66d, #ff6b9d);
background-size: 300% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent; /* 少了这句,黑字会盖在上面 */
animation: slide 6s linear infinite;
}
@keyframes slide {
to { background-position: 300% 0; }
}
模糊是一次对焦,眼睛把它读成深度
模糊/聚焦只动一个属性,白得三个效果:文字看起来像是在从后面来、从失焦里来、从光源里来。
为什么成立:你的视觉系统把「清晰度」当作距离线索。把模糊动到零,和「有东西移动到焦平面上」是同一个信号,所以大脑会自动补上缺失的深度,哪怕文字大小几乎没变。
代价是真的。对文字用 filter: blur() 会迫使每帧重绘,而且和 transform 不同,它交不给合成器。因此动画元素必须小——一个词没问题,一整段在中端手机上就掉帧。
呼吸字是同一个思路,只不过用的是 text-shadow 加一个很慢的 scale。那个 scale 才是它和普通透明度淡入淡出的分界:呼吸的东西读起来是活的,淡出的读起来像报错提示。
.focus-word {
animation: focus 4s ease-in-out infinite;
}
@keyframes focus {
0%, 100% { filter: blur(6px); opacity: .55; letter-spacing: .14em; }
45%, 60% { filter: blur(0); opacity: 1; letter-spacing: .04em; }
}
字距是故意和模糊一起动的——两者叠加读作「靠近」,只有模糊则读作「加载中」。
倒影:翻过来,淡下去,高度还得卡准
水面倒影是一个伪元素,一次纵向翻转、一层渐变遮罩、一点纵向偏移:
遮罩才是关键。只翻转一份,看起来就是一个副本;翻转之后在 40% 高度处淡出,才像水面。mask-image 干的是过去需要用第二块 canvas 做 destination-in 渐变的活。
那点偏移比看上去重要。设成 0,倒影紧贴正文,读起来像印刷错误。留几个像素的缝,再把倒影稍微压扁(scaleY(0.9)),才读得出「一个斜看过去的平面」。
.mirror { position: relative; }
.mirror::after {
content: attr(data-text);
position: absolute;
left: 0; top: 100%;
transform: scaleY(-1) scaleX(1.02); /* 翻转 + 轻微横向拉伸 */
transform-origin: top;
opacity: .35;
-webkit-mask-image: linear-gradient(to bottom, #000, transparent 60%);
mask-image: linear-gradient(to bottom, #000, transparent 60%);
pointer-events: none;
}
两条路各该什么时候用
采样流赢在「文字需要做一些字形做不到的事」:炸开、聚拢、响应指针、变成一万个独立的东西。代价是它是一张位图——不会重排,改字号必须重新采样,而且对屏幕阅读器不可见,除非你在背后留一个真的文本节点。
CSS 流赢在「重点就是排版本身」:颜色、字重、模糊、倒影。它仍然可选、可搜、可访问,还会跟随用户的字体设置。代价是那些绘制层的动画,以及一堆厂商前缀。
可访问性这条最常被跳过。本站这两个 canvas 示例都把真实字符串留在 DOM 里,视觉上藏起来但辅助技术读得到——因为一个「唯一标题是 canvas」的页面,等于一个没有标题的页面。
接下来可以怎么用
只在效果真的需要时才打碎字形。采样给你粒子,代价是失去文本;CSS 给你颜色和深度,文本原封不动。拿不准就先 CSS——而且无论如何,把真实字符串留在 DOM 里。
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 个特效