如何让滚动驱动的文字变色动画逐行从左向右依次进行?
现有的滚动进度计算可以保留。修改分行方式,让每个 .line 只包含一条可视行,渐变宽度与文字宽度一致,再将总进度分配到各行,就能让上一行完成后下一行再开始变色。
为什么当前代码无法正确逐行变色?
scrollWidth 无法可靠判断普通文本换行
当前 .line 使用 display: block,默认会占据容器宽度,其中的文字可以自动换行。文字放不下时,浏览器通常会增加元素高度,scrollWidth 不一定大于 quote.clientWidth。
因此,一个 .line 可能包含多条可视行。这些文字共用一个背景渐变,看起来就像多行同时变色。
原来的分行代码还有一个问题:它先把 testLine 写入旧行,发现溢出后又创建新行,却没有将旧行恢复为之前的 lineText。一旦进入这个分支,触发溢出的单词就会同时出现在旧行和新行中。
渐变百分比取决于元素宽度
块级 .line 默认与容器同宽。即使最后一行只有几个单词,渐变也按容器宽度计算。颜色边界越过文字末尾后,进度仍会继续,视觉上就会出现停顿。
让每行的宽度与文字一致,0% → 100% 就能对应这一行文字的完整变色过程。
原来的顺序计算基本正确
现有代码已经将前面的行设为 100%,当前行设为局部进度,后面的行设为 0%。也可以简化为:
const lineProgress = Math.min(
Math.max(progress * totalLines - i, 0),
1
);
其中 i 从 0 开始。只要分行正确,任意进度下最多只有一行的进度介于 0 和 1 之间。
实现方式:先测量行,再更新颜色
下面的示例适用于题目中的普通英文纯文本。它先让单词 span 参与浏览器排版,再根据垂直位置分组,生成各自独立的 .line。
示例会等待初始字体加载完成,并在窗口或文本容器宽度变化时重新分行。HTML 可以沿用问题中的结构。
CSS
.quote-text {
color: #bbb;
line-height: 1.5;
}
.quote-text .line {
display: block;
width: max-content;
white-space: pre;
--progress: 0%;
background-image: linear-gradient(
to right,
#000 0%,
#000 var(--progress),
#bbb var(--progress),
#bbb 100%
);
background-repeat: no-repeat;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
transition: none;
animation: none;
}
@media (prefers-reduced-motion: reduce) {
.quote-text .line {
--progress: 100% !important;
}
}
white-space: pre 防止生成好的行再次自动换行,width: max-content 让背景宽度跟随该行文字。两种颜色共用分界位置,因此变色边界清晰。
JavaScript
用下面的代码替换原来的 JavaScript:
document.addEventListener("DOMContentLoaded", async () => {
const quote = document.querySelector(".quote-text");
if (!quote) return;
const originalText = quote.textContent.trim();
if (!originalText) return;
const words = originalText.split(/\s+/);
const clamp = (value) => Math.min(Math.max(value, 0), 1);
let lines = [];
let measuredWidth = -1;
let buildFrame = 0;
let progressFrame = 0;
function updateProgress() {
if (!lines.length) return;
const rect = quote.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const progress = clamp(
(viewportHeight - rect.top) /
(viewportHeight + rect.height)
);
lines.forEach((line, i) => {
const localProgress = clamp(progress * lines.length - i);
line.style.setProperty(
"--progress",
`${localProgress * 100}%`
);
});
}
function buildLines() {
measuredWidth = quote.clientWidth;
if (!measuredWidth) return;
// Measure word positions using the browser's layout.
const measureFragment = document.createDocumentFragment();
const probes = words.map((word, i) => {
const span = document.createElement("span");
span.textContent = word;
span.style.display = "inline-block";
span.style.whiteSpace = "nowrap";
measureFragment.appendChild(span);
if (i < words.length - 1) {
measureFragment.appendChild(document.createTextNode(" "));
}
return span;
});
quote.replaceChildren(measureFragment);
const groups = [];
let currentTop = null;
probes.forEach((span, i) => {
const top = span.getBoundingClientRect().top;
if (currentTop === null || Math.abs(top - currentTop) > 1) {
groups.push([]);
currentTop = top;
}
groups[groups.length - 1].push(words[i]);
});
// Give each visual line its own gradient background.
const lineFragment = document.createDocumentFragment();
lines = groups.map((group) => {
const line = document.createElement("span");
line.className = "line";
line.textContent = group.join(" ");
lineFragment.appendChild(line);
return line;
});
quote.replaceChildren(lineFragment);
updateProgress();
}
function scheduleProgress() {
if (progressFrame) return;
progressFrame = requestAnimationFrame(() => {
progressFrame = 0;
updateProgress();
});
}
function scheduleBuild() {
if (buildFrame) return;
buildFrame = requestAnimationFrame(() => {
buildFrame = 0;
buildLines();
});
}
if (document.fonts) {
await document.fonts.ready;
}
buildLines();
window.addEventListener("scroll", scheduleProgress, {
passive: true
});
window.addEventListener("resize", scheduleBuild);
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(() => {
if (quote.clientWidth !== measuredWidth) {
scheduleBuild();
}
});
observer.observe(quote);
}
});
代码先完成全部测量,再统一写入最终的行元素。滚动时只更新 CSS 变量,不重新拆分文字。
每行如何严格按顺序变色?
假设有四行,整体滚动进度为 progress:
| 整体进度 | 第 1 行 | 第 2 行 | 第 3 行 | 第 4 行 |
|---|---|---|---|---|
0 | 灰色 | 灰色 | 灰色 | 灰色 |
0.125 | 完成一半 | 灰色 | 灰色 | 灰色 |
0.25 | 黑色 | 灰色 | 灰色 | 灰色 |
0.375 | 黑色 | 完成一半 | 灰色 | 灰色 |
0.5 | 黑色 | 黑色 | 灰色 | 灰色 |
1 | 黑色 | 黑色 | 黑色 | 黑色 |
每行占据相同长度的滚动区间。较短的行也分配到相同区间,因此不同长度行的颜色边界移动速度可能不同。
不要给 --progress 或相关视觉属性叠加有持续时间的过渡效果。否则快速滚动时,上一行可能还在追赶目标值,下一行就已经开始变化。
滚动范围和适用条件
示例保留了原来的进度公式:文本顶部到达视口底部时开始,文本底部到达视口顶部时完成。如果文本位于页面末尾,页面可能没有足够的滚动距离让它完全离开视口,动画就无法到达 100%。这时需要增加后续滚动空间,或者将终点提前到文本仍在视口内的位置。
还需要注意以下边界:
- 这是滚动位置驱动的动画,向上滚动时会反向恢复灰色。若希望滚过后永久保持黑色,需要另外记录最大进度。
- 示例按空白拆分英文单词,并将单词作为不可拆分的测量单元。超长单词可能溢出;中文、自动断词和复杂混排需要按字符或文本范围测量实际行位置。
- 示例会重建元素内容,适合题目中的纯文本。包含链接、强调标签等结构时,需要保留节点结构,不能直接套用
textContent重建。 - 后续动态修改字体、字号或文字内容时,也需要重新分行;仅更新颜色进度无法修正已经变化的行边界。
备注:内容仅供参考。
