PAYATHON 2026

如何让滚动驱动的文字变色动画逐行从左向右依次进行?

数据阿明

现有的滚动进度计算可以保留。修改分行方式,让每个 .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 重建。
  • 后续动态修改字体、字号或文字内容时,也需要重新分行;仅更新颜色进度无法修正已经变化的行边界。

备注:内容仅供参考。