文字扫光:让一条亮带扫过字形
标题、按钮、loading 文案上那种「一道高光缓缓划过文字」的效果,拆开看只有两件事:把渐变灌进字形(background-clip: text,详见
CSS Background 系列的「灌进字形」一节),再让这张渐变横向平移。本页把它做成可调参数的现场 demo,并对照分析一个流行的 React 实现
gradient-shimmer 的三处关键设计:对称亮带渐变、随字号自适应的亮带宽度、以及为什么用 Web Animations API 而非纯 @keyframes 来驱动。
1 · 一行话的本质
文字本身用 -webkit-text-fill-color: transparent 镂空成「透明字」,底下铺一张比文字宽得多的渐变,background-clip: text 把这张渐变裁进字形轮廓——于是看见的「字色」其实是渐变。再用动画把 background-position 从最左推到最右,渐变里那条亮带就扫过了每个字:
.shimmer {
background-image: linear-gradient(105deg, 底色, …亮带…, 底色);
background-size: 比文字宽很多 px 100%; /* 留出滑动空间 */
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent; /* 字镂空, 露出渐变 */
}
/* 把背景从左滑到右, 亮带就扫过每个字 */
@keyframes shimmer { to { background-position: 100% center; } }
2 · 现场效果
拖动下面的参数,实时看亮带如何变化;下方的代码面板是当前这一帧真正生效的 CSS 与 WAAPI 调用。这个 demo 也接入了「滚出视口自动暂停」:把它滚出屏幕动画会冻结,滚回来再继续。
3 · 关键一:渐变不是两色渐变,是一条对称亮带
若只是
,扫过去会像「半边亮半边暗」地推移,不像一道划过的高光。gradient-shimmer 的渐变以中点 50% 对称:两端是底色,正中嵌入你给的多个色标(stops),并在两侧用 color-mix(in oklab, 底色 42%, 边色) 做柔和过渡,形成一条有宽度、两头淡出的亮带:
linear-gradient(105deg,
底色 calc(50% - var(--gs-spread)), /* 左侧纯底色 */
color-mix(in oklab, 底色 42%, 首色) calc(50% - var(--gs-spread-mid)), /* 淡入 */
…各 stop: 色 calc(50% + var(--gs-spread-mid) * k)… /* 亮带主体 */
color-mix(in oklab, 底色 42%, 末色) calc(50% + var(--gs-spread-mid)), /* 淡出 */
底色 calc(50% + var(--gs-spread))) /* 右侧纯底色 */
两个细节: 其一,亮带宽度全靠两个自定义属性 --gs-spread / --gs-spread-mid(后者约前者的 0.72 倍)撑开,渐变模板本身不写死像素——改宽度只改这两个变量,不必重拼整条渐变串。其二,过渡色用
而非默认的 sRGB 插值:oklab 是感知均匀色空间,底色与亮色之间不会像 sRGB 那样途经发灰的中间色。
4 · 关键二:亮带宽度随字数与字号自适应
一句短词和一整行长句,若用同样的像素亮带,观感的「扫光节奏」会完全不同。所以 spread 不是固定像素,而是按内容算出来、并随 font-size 缩放,再封一个上限:
const fontScale = 当前 fontSize / 14;
const spreadPx = Math.min(字符数 * spread * fontScale, 48 * fontScale); // 有上限
const bgWidth = 元素宽度 + spreadPx * 2; // 背景比文字宽这么多
el.style.backgroundSize = `${bgWidth}px 100%`;
el.style.setProperty('--gs-spread', `${spreadPx}px`);
el.style.setProperty('--gs-spread-mid', `${spreadPx * 0.72}px`);
// 平移区间: 从背景完全在左侧外, 滑到完全在右侧外
const start = -spreadPx - bgWidth / 2;
const end = 元素宽度 + spreadPx - bgWidth / 2; // 平移量恰好 = bgWidth, 扫满一趟
平移距离正好等于 bgWidth,意味着背景滑过自身整整一个宽度——亮带从字的最左外侧一路扫到最右外侧,不多不少正好一趟。元素宽度靠 getBoundingClientRect() 实测,所以换文字、换字号、容器 resize 后都得重新测量重算,这也是它离不开 JS 的原因之一(纯 CSS 拿不到「比文字宽 N
像素」这种依赖测量的尺寸)。
5 · 关键三:为什么用 WAAPI,而不是纯 @keyframes
既然每次都要 JS 测量、写 background-size 和几何,索性连动画也交给 element.animate()(Web Animations API)。它比纯 CSS 多给三样能力:
| WAAPI 比纯 CSS 多给的能力 | 说明 |
|---|---|
| 每轮之间停一拍(pauseBetween) | 扫完一趟停 1 秒再扫,比无停顿循环更耐看。WAAPI 在 animation.onfinish 里 setTimeout 一拍再重启即可;纯 @keyframes 要靠在关键帧里塞一段「不动」的占比硬凑,改时长就得重算占比。 |
| 精确 play / pause / cancel | 拿到 Animation 实例,可随时 pause() / play() / cancel() 并替换成新参数的动画——改 spread / 角度时直接 cancel 旧的换新的,无需操作 class 或 stylesheet。 |
| 按可见性暂停,省电省 CPU | 用 IntersectionObserver(滚出视口)+ visibilitychange(切到别的 tab)两者组合,任一不满足就 pause()。本页的 demo 已接入这套逻辑:把它滚出屏幕,动画会冻结,滚回来再继续。 |
纯 CSS 能不能做? 能——若不要「自适应宽度 / 间歇 / 离屏暂停」,一句 animation: shimmer 1.45s linear infinite 配固定的 background-size: 200% 100% 就够了。要不要上 JS,取决于你是否需要上面那三样;gradient-shimmer 选了 JS,是因为它把这些都做进了组件的可配置项。
降级与无障碍: 不支持 background-clip: text 时(CSS.supports 探测),移除透明填充让文字正常显示;命中 prefers-reduced-motion: reduce 时不滚动,只把亮带静止摆在文字正中。两者本页都已处理,详见
尊重「减少动态效果」一页的通用做法。