CSS文字扫光效果:background-clip实现流光文字与性能优化
1. 先把“亮光”这件事拆开来看
文字高光滑动效果,圈子里也有人叫文字扫光、流光文字、左侧到右侧亮光动画。它的视觉本质不是“渐变文字”,而是让一束窄而亮的光带沿着文字笔画从左向右掠过,剩下的部分保持低亮度。这个区别非常关键:前者是静态装饰,后者是动效叙事。做静态渐变文字,一行linear-gradient加background-clip: text就收工了;做扫光,你得同时控制三件事——光带形状、光带位置、位置随时间的变化曲线。少一样,出来的东西要么像卡住的色块,要么像廉价的闪烁。
我做这个效果最早是在一个数据看板项目里。某公司的运营大屏需要在夜间模式下突出几组关键指标,纯白文字在大屏上根本抓不住眼睛,加粗又破坏排版节奏。试了一圈,最后落在这个方案上:文字主体保持 70% 灰,一条接近纯白的高光从左侧平滑滑到右侧,循环周期两秒半左右,视觉上既不抢戏,又能持续把视线往数字上拽。后来这个组件被抽出来复用到了加载提示、按钮文案、榜单标题等各种地方,慢慢就攒出了一套固定写法。
适合谁看?如果你写过 CSS 动画、知道@keyframes怎么写,这篇可以让你直接抄走一套生产可用的组件;如果你刚入门,文中每个参数我都会给出计算过程,跟着推一遍就能明白为什么background-size要设成 250% 而不是 100%。下面所有内容都围绕“左侧到右侧亮光”这一个目标展开,不跑题。
1.1 视觉目标:不是渐变文字,是“移动的一束光”
先明确目标物的形态。一束自然的光,应该有三个特征:中间最亮、两侧快速衰减、边界不能出现生硬的直线。对应到 CSS 渐变里,就是三个以上色标停靠点,且亮带两侧的过渡区间要有一定宽度。如果你只写linear-gradient(90deg, #666, #fff, #666),色标默认均匀分布在 0%、50%、100%,结果是整段文字从暗到亮再回到暗,看着像“呼吸”,不像“扫光”——因为亮带宽度等于整个文字宽度,根本没有“带”的概念。
我一般会把亮带宽度控制在整段文字宽度的 8% 到 15% 之间。太窄了像一道划痕,高频闪烁感强;太宽了就失去“一束光”的锐度,变成整体亮度波动。这个比例不是拍脑袋的,它跟视口宽度和字号都有关系:字号越大、文字越长,亮带相对宽度可以适当加大,否则在大屏上会显得过于纤薄。反过来在移动端小字号场景,亮带比例超过 20% 就会糊成一片,看不出滑动。
还有一个容易被忽略的细节是“余晖”。真实的光扫过物体表面,前后会有一点残留亮度。我在色标里通常会在亮带两侧各加一个略高于底色的过渡色,比如底色#8a8a8a,亮带#ffffff,中间插一个#c8c8c8,这样扫过去的时候边缘不会像刀切一样干净,整体质感会厚一些。这个技巧在深色背景上尤其明显。
1.2 三条技术路线的取舍
实现这个效果,我试过三条路,各自的成本和使用场景差别很大。
第一条是background-clip: text配合渐变背景平移动画。思路很直接:把一段渐变当成背景图,用background-clip: text裁成文字形状,然后让这张背景图在文字内部平移,亮带就跟着动了。优点是代码量极小。十条属性左右就能跑起来;文字仍然是真实文本,可选中、可被搜索引擎读到、屏幕阅读器正常朗读。缺点也明确:background-position的动画走的是重绘流程,不是合成层动画,文字特别多的时候帧率会有压力。
第二条是遮罩叠加。先正常渲染一份文字,再复制一份同样内容的文字作为伪元素,给它一个亮色,然后用mask-image或者clip-path做一个移动的窄条,只让这份亮色文字在窄条范围内显示。这个方案的动画可以完全交给transform,走 GPU 合成,性能最好。代价是文本要渲染两遍,DOM 结构变复杂,而且伪元素里的文字没法自动同步内容,只能靠content: attr(data-text)这种取巧写法,遇到富文本、换行、不同字重就不好办了。
第三条是纯伪元素高光块。在文字容器上叠一个绝对定位的白色斜条,用mix-blend-mode混合到文字上。这个方案做出来的效果最“电影感”,因为高光块可以带模糊、可以带角度、可以带光晕。但它有个致命缺陷:高光块是矩形的,它不会只出现在文字笔画上,而是整个区域一起亮。除非你的文字本身是纯色大色块背景,否则这个方案在普通排版里根本不能用。我在一个纯黑背景的活动页上用过一次,效果确实好,但那个页面所有文字都是超大字号的单行标题,属于特例。
| 方案 | 动画属性 | 性能表现 | DOM 复杂度 | 文本可编辑性 | 适用场景 |
|---|---|---|---|---|---|
| background-clip + background-position | background-position | 中等,重绘文字区域 | 单层,最简 | 完全保留 | 通用场景,正文标题、按钮文案 |
| 复制文字 + mask/clip-path | transform | 优秀,合成层 | 双层,需同步内容 | 部分受限 | 大字标题、首屏焦点 |
| 伪元素高光块 + 混合模式 | transform/opacity | 优秀 | 双层 | 完全保留 | 纯色背景上的超大字 |
1.3 选定 background-clip 的理由
日常用得最多的还是第一条。原因很朴素:这个效果的视觉面积通常很小,就是一行字,重绘的成本远没有想象中高。一段 24 像素字号、二十个字以内的标题,重绘区域的像素量大概就是几十万级别,现代浏览器在 60 帧下完全扛得住。我实测过在一个列表页里同时跑二十个这样的动画,帧率稳定在 58 到 60 之间,掉帧不明显。真正会出问题的是整屏大字号、几十个元素同时动的极端情况,那种场景我才会切到 mask 方案。
更重要的是可维护性。background-clip: text的写法里,文字内容完全在 HTML 里,改文案不用动 CSS,也不需要>.shine-text { color: transparent; -webkit-text-fill-color: transparent; }
还有一个推论:文字的抗锯齿边缘是半透明的。因为背景被裁到笔画形状,边缘像素在裁剪时也是半透明的,所以文字的边缘会出现轻微的发虚或者彩边,尤其是深色背景上的浅色渐变。解决办法是在元素上补一句-webkit-font-smoothing: antialiased,让浏览器用灰度抗锯齿代替子像素抗锯齿,边缘会干净很多。这个不是万能的,某些情况下还是会有细微彩边,但比不加要好得多。
2.2 用色标堆出一束光
渐变本身不难,难的是色标怎么排。我习惯用linear-gradient(100deg, ...)而不是标准的 90 度,理由是这样的轻微倾斜让人眼更容易感知到“斜向扫过”的方向性,纯水平的滑动在视觉上会有点呆。100 度大约是向右下方偏 10 度,幅度很小但有效。有些设计稿会要求 135 度的大斜角,那种看起来更像“反光”,也很常见,看你需要的调性。
色标的排布我一般这么算。假设底色是#8a8a8a,高光是#ffffff,亮带宽度占整张背景图的 12%,位置放在中间:
background-image: linear-gradient( 100deg, #8a8a8a 0%, #8a8a8a 38%, #b8b8b8 44%, #ffffff 50%, #b8b8b8 56%, #8a8a8a 62%, #8a8a8a 100% );从 38% 到 50% 是渐入,50% 是峰值,50% 到 62% 是渐出,整个亮带宽度是 24%。这里有个重要认知:这个 24%是相对背景图宽度的,不是相对文字宽度。因为后面我们会把background-size设成 250%,所以亮带相对文字的实际宽度是 24% × 250% ≈ 60%?不对,方向反了——背景图变宽了,亮带在图里的绝对尺寸也变宽了,但文字只显示图的 40%,所以亮带占文字宽度的比例是 24% × 2.5 ÷ 1 = 60%(这里指的是亮带宽度占元素可见区域的比例,即 24% × 2.5 = 60% 的元素宽度)。
这个数字明显太大了,会让整段文字几乎全是亮的。所以实际使用中,色标的间距必须调小。如果background-size是 250%,想让亮带占文字宽度的 15%,那么色标跨度应该是 15% ÷ 2.5 = 6%。也就是说,从渐入开始到渐出结束总共只占 6% 的图宽。这样色标就得排得很密:
background-image: linear-gradient( 100deg, #8a8a8a 0%, #8a8a8a 47%, #d0d0d0 49%, #ffffff 50%, #d0d0d0 51%, #8a8a8a 53%, #8a8a8a 100% );这段看起来有点反直觉,但算一遍就清楚了:6% 的图宽乘以 2.5 倍,等于 15% 的元素宽度。刚上手时我在这里翻过车,色标写得很大,结果亮带糊满整行,调了半小时没找出原因。后来养成了习惯:先定background-size,再按比例反推色标,一次就对了。
2.3 background-size 与 background-position 的联动计算
这是整个效果最容易出错的地方。background-position的百分比语义跟很多人直觉相反,它不是“背景图左边缘距容器左边缘的距离占容器宽度的百分比”,而是“背景图与容器按比例对齐”。公式是这样的:
背景左边缘偏移量 = position% × (容器宽 − 背景宽)因为background-size: 250%时背景宽是容器宽的 2.5 倍,所以(容器宽 − 背景宽) = −1.5 × 容器宽。代入:
background-position: 0%→ 偏移量 = 0,背景图左边缘贴容器左边缘,容器显示背景图的左 40%background-position: 100%→ 偏移量 = −1.5 × 容器宽,背景图左边缘跑到容器左边外面,容器显示背景图的右 40%
现在假设亮带在背景图的 50% 位置(图宽 2.5W,所以绝对位置是 1.25W):
- 位置 100% 时:亮带在容器坐标系里的位置 = 1.25W − 1.5W = −0.25W,在容器左侧外面,看不见
- 位置 0% 时:亮带在容器坐标系里的位置 = 1.25W − 0 = 1.25W,在容器右侧外面,看不见
- 位置 50% 时:偏移量 = 0.5 × (−1.5W) = −0.75W,亮带位置 = 1.25W − 0.75W = 0.5W,正好在容器正中
所以结论很明确:background-position从 100% 走到 0%,亮带就从左到右穿过整个容器。这就是标题里“左侧到右侧”的实现方式。如果你写成从 0% 到 100%,方向就反了,光会从右往左走。这个结论我在好几个项目里跟同事解释过,画个图一目了然,纯靠试是试不出来的。
还有一个隐含条件:background-position能产生位移的前提是背景图尺寸不等于容器尺寸。如果background-size: 100%,公式里(容器宽 − 背景宽) = 0,不管 position 设多少偏移量都是 0,动画完全不动。这就是很多人写完发现“亮带卡住不动”的根本原因。所以background-size至少要大于 100%,实践中 200% 到 400% 之间比较合适。倍数越大,亮带走得越慢、越平缓,因为同样的位移量分摊到了更大的图上。
3. 手写一版可复用的高光组件
原理清楚了,接下来落到代码。我给出一版我目前项目里在用的实现,然后逐行拆解参数,最后把它改造成 CSS 变量驱动的组件,方便不同场景传参。
3.1 最小可用骨架
<h1 class="shine-text">夜间模式下的关键指标</h1>.shine-text { /* 基础排版 */ font-size: 32px; font-weight: 600; letter-spacing: 0.02em; line-height: 1.4; /* 渐变与裁切 */ background-image: linear-gradient( 100deg, #8a8a8a 0%, #8a8a8a 47%, #d0d0d0 49%, #ffffff 50%, #d0d0d0 51%, #8a8a8a 53%, #8a8a8a 100% ); background-size: 250% 100%; background-position: 100% 0; background-repeat: no-repeat; -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; -webkit-font-smoothing: antialiased; /* 动画 */ animation: shine-sweep 2.4s linear infinite; } @keyframes shine-sweep { 0% { background-position: 100% 0; } 100% { background-position: 0 0; } }这段代码在任何现代浏览器里都能直接跑。注意background-repeat: no-repeat,不写的话在某些background-size数值下边缘会出现重复的亮带,看着像两条光同时在跑。animation-timing-function: linear是刻意的,扫光这种匀速运动用缓动函数会显得拖沓,ease 开头快结尾慢,光会在右侧“赖着不走”,很别扭。
3.2 参数逐行拆解与关键取舍
font-size和letter-spacing会影响扫光的观感。字间距加大之后,光带在字与字之间的空隙里没有可显示的内容,视觉上会出现断续。如果你的排版本身字距较松,建议把亮带宽度调大一些,让相邻字之间的衔接更连贯。相反,如果字距为负(紧凑标题常用),光带会显得更连续,但亮带太宽的话整行会一起亮,失去扫动感。我的经验值是:正常字距下亮带占元素宽度 12% 到 18% 之间最舒服。
line-height这个参数很多人不设,默认值也行,但如果你的文字有多行并且行高很小,background-clip: text在高行密排时会有一个小问题:行与行之间的间距区域没有文字笔画,背景在那里被裁掉,所以光带在行间是断开的。这是正常现象,不用处理。但如果行高极大,断开感会很明显,看起来像好几条独立的光在同时跑,这时候可以考虑改用 mask 方案。
渐变里的47% / 49% / 50% / 51% / 53%这五个点,是我反复调出来的。47% 到 49% 是渐入区间,只有 2% 的图宽,对应元素宽度的 5%;50% 是峰值;51% 到 53% 是对称的渐出。这个宽度下,亮带边缘足够柔和,又不会宽到糊成一片。如果你希望光更“锐”,可以把渐入渐出压到 1%,但如果压到 0.5% 以下,在低分辨率屏幕上会出现明显的色阶断层,看着像一根硬边白条。
background-size: 250% 100%里的纵向 100% 是我特意写死的。有些人会写成250%(省略第二个值),这时候纵向会按auto处理,虽然在这个场景下结果一样,但显式写100%更安全,避免元素高度变化时出现意外。横向 250% 意味着亮带在元素可见区域内的移动速度是 position 变化速度的 1/1.5,换句话说,position 走完 100% 的行程,亮带只走完了元素宽度的约 1.5 倍距离。这个比例让扫光的节奏不会太快。
animation: 2.4s这个时长是体感调出来的。低于 1.5 秒会显得焦躁,像报警灯;高于 4 秒又太慢,用户可能根本没注意到光扫过了。2 到 3 秒之间是比较舒服的区间,具体数值跟着亮带宽度走:亮带越窄,周期可以越短,因为它需要更快地掠过才不显得拖沓。
3.3 升级成 CSS 变量驱动的可配置组件
直接在项目里复制粘贴这段 CSS,很快就会遇到问题:不同页面需要不同的颜色、不同的速度、不同的方向,改起来很麻烦。我的做法是把它抽成一个类,用 CSS 变量暴露关键参数,调用方只需要覆盖变量。
.shine-text { --shine-base: #8a8a8a; --shine-peak: #ffffff; --shine-mid: #d0d0d0; --shine-duration: 2.4s; --shine-delay: 0s; --shine-size: 250%; --shine-width: 3%; --shine-start: calc(50% - var(--shine-width)); --shine-end: calc(50% + var(--shine-width)); background-image: linear-gradient( 100deg, var(--shine-base) 0%, var(--shine-base) calc(var(--shine-start) - 3%), var(--shine-mid) var(--shine-start), var(--shine-peak) 50%, var(--shine-mid) var(--shine-end), var(--shine-base) calc(var(--shine-end) + 3%), var(--shine-base) 100% ); background-size: var(--shine-size) 100%; background-position: 100% 0; background-repeat: no-repeat; -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; animation: shine-sweep var(--shine-duration) linear var(--shine-delay) infinite; }这样用的时候就很清爽:
.title-main { --shine-base: #5b6b7c; --shine-peak: #ffffff; --shine-duration: 3.2s; --shine-width: 4%; }这里用calc()算色标位置,好处是改--shine-width一个值,整条亮带的宽度和对称性都跟着变,不用手动改五六个数字。需要注意的是calc()内部再做嵌套calc()在某些老版本浏览器上有兼容问题,我这里的写法已经是最外层展开的,只有一层嵌套,实测没问题。
提示:如果你的项目用构建工具,把这段 CSS 放在全局样式里,元素上只挂类名,不要用内联 style 传变量,内联样式在主题切换时会覆盖样式表里的变量,导致换肤失效。
4. 性能、兼容与降级处理
效果跑起来之后,接下来要面对的是真实项目里的三个硬骨头:帧率、浏览器差异、无障碍。这三块处理不好,代码在本地演示很漂亮,一上线上环境就出问题。
4.1 重绘成本与合成层
background-position的动画,浏览器走的是重绘流程。每一帧都要重新计算背景的绘制位置,然后重新光栅化文字区域的像素。这个成本跟文字的面积成正比,跟同屏动画数量成正比。一行字的成本可以忽略,但如果你在长列表里给每一项都挂了扫光,五十项同时跑,那就另说了。
我做过一轮实测,在一个中等配置的笔记本上,同屏 50 个元素、每个元素一行 20px 字号的文本同时跑扫光动画,帧率会掉到 40 帧左右,能感觉到轻微的顿挫。降到 20 个以下就基本无感了。所以我的做法是:列表项不加扫光,只给首屏标题、关键指标、重点按钮加。这个取舍很实际,用户的眼睛也只会盯住一两个焦点,全都闪反而没有重点。
如果确实需要大量元素同时动,可以考虑几个优化手段。第一,把will-change: background-position加上,让浏览器提前提升图层。但要慎重,这个属性会让元素常驻一个合成层,占显存,用多了反而拖慢整体。第二,只在元素进入视口时才启动动画,用IntersectionObserver控制类名,离开视口就停。第三,降低动画频率,比如把时长拉到 4 秒以上,虽然慢但每帧的计算量没变,这个手段其实没用,别指望。第四,改用 mask 方案,把动画挪到transform上。
mask方案的写法大概是这样:父元素position: relative,文字本身正常着色,伪元素content: attr(data-text)复制文字并设为白色,然后给伪元素加上mask-image: linear-gradient(100deg, transparent 40%, #000 50%, transparent 60%)配合background-size类似的技巧,最后用transform: translateX()移动 mask。这套方案能跑满 60 帧,但 HTML 里必须写>.shine-text { color: #8a8a8a; } @supports ((-webkit-background-clip: text) or (background-clip: text)) { .shine-text { background-image: linear-gradient(/* ... */); background-size: 250% 100%; background-position: 100% 0; background-repeat: no-repeat; -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; animation: shine-sweep 2.4s linear infinite; } }
这种写法的好处是,降级分支里的文字颜色是显式声明的,不会被透明色污染。我见过有人把color: transparent写在外面,结果在不支持的环境里文字直接消失了,页面看起来像缺了一块,非常尴尬。
还有一个坑是打印样式。用户把页面打印出来的时候,透明文字加背景裁切在某些打印引擎下会变成全白,纸上一片空白。我通常会在打印媒体查询里直接把效果关掉:
@media print { .shine-text { background: none; color: #000; -webkit-text-fill-color: #000; animation: none; } }4.3 动效偏好与无障碍
扫光本质上是持续的循环动画,对一部分用户来说是干扰。系统层面有个“减少动态效果”的开关,CSS 里对应prefers-reduced-motion媒体查询。尊重这个设置不是可选项,是基本素养。
@media (prefers-reduced-motion: reduce) { .shine-text { animation: none; background-image: none; color: #8a8a8a; -webkit-text-fill-color: #8a8a8a; } }注意这里要把background-image也清掉。只关animation的话,背景图会停在background-position: 100%的位置,文字可能刚好落在亮带之外,看起来是暗的;也可能刚好落在亮带上,一整段文字全是白的。这两种结果都不受控,所以干脆整体回退成纯色最稳。
还有个细节是文本选中态。文字被-webkit-text-fill-color: transparent掏空之后,用户拖选这段文字时,系统的选中高亮可能看不清,因为选区背景是浏览器默认的蓝色,而文字本身是透明的,选区里看起来像一片蓝块。解决办法是单独给::selection设一下:
.shine-text::selection { background: #3a6ea5; color: #ffffff; -webkit-text-fill-color: #ffffff; }屏幕阅读器这边不需要特别处理,因为文字内容始终在 DOM 里,没有被伪元素替换掉,朗读完全正常。这也是我偏爱这个方案的重要原因之一。
5. 排查实录:那些让人卡半天的坑
这个效果的代码很短,但出错的方式出奇地多。下面这张表是我这几年攒下来的,基本上能覆盖九成以上的“为什么没效果”。
| 现象 | 大概率原因 | 快速验证手法 | 处理方式 |
|---|---|---|---|
| 文字整个消失 | 写了color: transparent但没写裁切 | 临时把color改回具体色 | 补-webkit-background-clip: text和标准写法 |
| 只有部分浏览器生效 | 缺-webkit-前缀 | 打开检查器对比计算样式 | 双写前缀与标准属性 |
| 亮带动都不动 | background-size是 100% | 检查该项计算值 | 改成 200% 以上 |
| 亮带从右往左跑 | 动画首尾 position 写反 | 对比0%和100%两帧 | 交换起止值 |
| 循环时出现跳帧 | 渐变首尾颜色不一致 | 比对 0% 和 100% 的色标 | 两端的底色必须完全相同 |
| 亮带边缘有硬边 | 色标跨度太小 | 放大截图看过渡区 | 渐入渐出各留 2% 以上 |
| 文字边缘发虚或彩边 | 子像素抗锯齿 | 换个背景色观察 | 加-webkit-font-smoothing: antialiased |
| 动画结束瞬间闪一下 | infinite循环首尾不接 | 把时长拉长观察 | 保证 position 首尾构成完整行程 |
| 打印出来是空白 | 透明填充在打印引擎下失效 | 打印预览 | 打印媒体查询里回退纯色 |
| 移动端掉帧 | 同屏动画数量过多 | 关掉部分元素对比 | 视口内才启动,或改用 mask 方案 |
挑几个重点的说。
第一个是循环跳帧。这是最常见的“明明能跑但看着不舒服”的原因。动画从 100% 走到 0%,然后瞬间跳回 100%,如果渐变图案在 100% 位置和 0% 位置呈现的内容不一样,就会看到一次突变。要避免这个,要么保证渐变在整张图上是首尾相接的(两端都是底色),要么把animation-direction设成alternate让它往复播放。我倾向于前者,往复播放会让光来回扫,方向感混乱,不如单向循环来得自然。用前者的话,色标的第一个和最后一个必须严格是同一个底色值,差一点点就能看出来。
第二个是background-size的倍数与亮带速度的关系。有人觉得动画太快,第一反应是改animation-duration,这没问题。但如果同时改了background-size,会发现速度变化不成比例。原因是速度取决于 position 的行程和图的宽度两个因素,改倍数会同时影响这两者。我的建议是:只用一个旋钮调速,就是animation-duration,background-size定下来就别动了。
第三个是文字内容的边界。如果文字末尾是标点符号,比如句号、引号,这些字符的笔画面积小,光扫过去的时候会在标点处一下“消失”,导致光的连续性被打断。这个没法从技术上完全解决,属于排版层面的问题。我一般的处理是把带结尾标点的文字拆成两个 span,标点部分不参与扫光,或者干脆在文案层面避免让扫光元素以标点结尾。
第四个是在 Flex 或 Grid 布局里,如果父容器设置了overflow: hidden且元素高度超出,background-clip: text会在被裁掉的部分保留背景,视觉上可能出现文字被切一半但光还在继续的情况。这个属于正常行为,但看起来会有点怪,遇到的话检查一下父级溢出设置。
注意:不要给扫光元素加
text-shadow。阴影是在文字层之上绘制的,它会盖住被裁切的背景,结果就是亮带被一层灰蒙蒙的阴影糊住,效果全毁。想要发光感,用filter: drop-shadow()加在容器上,它作用在渲染后的整体形状上,不会产生这个问题。
6. 进阶玩法:让这束光用在真实页面里
基础的无限循环跑通之后,实际项目里往往需要更克制的用法。持续闪烁的标题看久了很烦,真正好用的场景是“有触发条件的扫光”。
6.1 悬停触发单次扫光
按钮文案是最典型的需求。默认状态下文字是静态的灰色,鼠标移上去,一束光从左到右扫过,然后停在结束位置不再循环。这种用法需要把动画从元素本身挪到:hover状态上,并且设置animation-iteration-count: 1。
.btn-text { --shine-duration: 0.9s; background-image: linear-gradient(/* ... */); background-size: 250% 100%; background-position: 100% 0; -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; animation: none; } .btn:hover .btn-text { animation: shine-sweep var(--shine-duration) linear 1; }这里有个细节坑:动画跑完之后,background-position会回到元素本身的声明值(100% 位置),也就是亮带在左边外面,文字看起来是均匀的底色,这个结果是对的。但如果你的默认 position 和动画终点不一致,松手之后会出现一次“回弹”。所以默认的background-position必须和动画的最后一帧保持一致,这是关键。
还有,鼠标快速移入移出时,动画会被反复打断重启,看起来像抽搐。我的处理是给按钮加一个pointer-events的节流,或者干脆把时长压到 0.6 秒以内,让它在用户移出前就跑完。工程上更稳的做法是用 JS 加一个短暂的锁,不过这就超出纯 CSS 的范围了。
6.2 多行文字错峰入场
首屏大字标题,一行一行地扫,而不是同时扫,观感会高级很多。实现方式很简单,给每个行元素加不同的animation-delay:
.line-1 { animation-delay: 0s; } .line-2 { animation-delay: 0.15s; } .line-3 { animation-delay: 0.3s; }延迟的间隔我一般取单个动画时长的 1/8 到 1/12,太密了看不出错峰,太疏了整个入场节奏拖沓。三行以内用这个手法效果最明显,超过五行就没必要了,后面几行用户可能还没看到就扫完了。
需要留意的是,如果这些行元素是在滚动到视口时才加载的,那么延迟的基准时间要从元素可见那一刻算起,而不是页面加载时刻。这时候就得靠IntersectionObserver加类名,CSS 里的animation-delay才会重新开始计时。
6.3 与骨架屏、SVG 的组合
同样的思路在骨架屏上也很好用,只是把background-clip: text去掉,让渐变直接作用在一个圆角灰色块上。骨架屏的扫光方向和文字扫光应该保持一致,页面里两种光同时从不同方向扫过会显得很杂。
跟 SVG 文字配合时要注意,SVG 里的<text>元素不支持background-clip,得换一条路。我用的做法是给<text>加一层fill引用linearGradient,然后用animate或者 CSS 动画去改x1/x2或者 gradient 的gradientTransform。这条路的性能比 HTML 版本好,因为 SVG 的渐变重绘范围更精确,但代码量翻倍,只在大尺寸主视觉才值得上。
还有一个场景是纯装饰性的光带掠过一张卡片,不只是文字。这时候可以把background-clip: text换成mask-image,让光带只出现在卡片的圆角范围内。这套组合我做在一个活动落地页上,卡片本身是半透明玻璃质感,光带掠过的时候边缘会有折射感,成本很低但出效果。
我个人的经验是,这个效果真正的价值不在于“炫”,而在于它能用一个很低的成本,在静态排版里制造出一个视觉锚点,把用户的注意力在某个瞬间拉到你想让他看的地方。用得克制一点,一屏里只出现一两次,效果远好过满页都在闪。
最后分享一个小技巧:调参数的时候,把animation-duration临时改成10s,你会把整个扫光过程看得清清楚楚,色标是否对称、亮带宽度是否合适、首尾是否接得上,一眼就能看出来。调满意了再改回正常速度,这个笨办法帮我省了大量反复试错的时间。