Goals 手机端划线“提前跑完”?一次 pinSpacing=false 引发的错觉与修复

移动端看起来像“下划线动画没了”,但其实是上游 pinned section 让滚动条提前推进。记录从 markers 误判到根因定位,以及一个不改上游布局的折中修复。

January 29, 2026 (3w ago)
7 min read
2,889 views
219 likes
Category
Intermediate
#GSAP#ScrollTrigger#pin#pinSpacing#Mobile#UI#Debugging#Next.js

Goals 手机端划线“提前跑完”?一次 pinSpacing=false 引发的错觉与修复

这次的 bug 乍看很简单:手机端 Goals 文案的划线和颜色变深效果没了
但实际情况更“反直觉”:动画并没有消失,而是——在你真正看到文字之前,它已经跑完了

本文记录从现象、排查、到最终修复的全过程,并给出一个我认为更平衡的方案:不改上游布局,改用“真实视口位置”驱动文案进度


现象:我滚到了 Goals,却看不到划线动画

受影响组件:

  • src/components/goals-showcase/GoalsCardShowcase.tsx

移动端区域(lg:hidden)有三行大标题,预期效果是:

  • 文字从灰色逐渐变深
  • 下划线(用 background-size 实现)从 0% 拉到 100%

但在移动端模拟器(Chrome / Edge)里,肉眼看到的结果是:它们一出现就是“最终态”,于是很容易误判成“动画没了”。


第一步排查:加 markers + 日志,确认 ScrollTrigger 是否在跑

我先做了最直观的验证:

  • 确认 mobile 分支代码是否执行(matchMedia)
  • 确认拿到 3 个 span(ref 列表)
  • 给每个文字 trigger 开 markers,滚动时打印 progress

调试开关:

  • 打开首页时带上 ?goalsDebug=1

结果非常关键:

  • count: 3 ✅(节点拿到了)
  • onUpdate progress 一直在变化 ✅(触发器在跑)
  • markers 显示 trigger 的 start/end 也在正常移动 ✅

所以问题不是“ScrollTrigger 不工作”,而是——它工作得太早了


关键线索:markers 已到 scroller-end,但屏幕还停在上一个 pinned section

当我把页面从头滚到尾,看到的 markers 截图大概是:

  • Goals 的 scroller-end 已经过去
  • 但屏幕视觉上仍停留在上游的 pinned 动画区域

这意味着一件事:滚动条(scrollbar)在推进,但视觉内容并没有按同等比例往下“露出”


Root Cause:上游 pinSpacing=false 造成“滚动条推进 ≠ 视觉推进”

在首页 Goals 之前有一个文字滚动 section(TextScrollSection),里面存在 pin: truepinSpacing: false 的配置:

  • src/components/ui/text-scroll-section.tsx

pinSpacing: false 时:

  • pinned 元素会固定在视口里
  • 但 ScrollTrigger 不会在文档流中插入 spacer 占位

结果就是:

  • 页面滚动值继续增加
  • 但后续 section 在视觉上还没出现
  • 于是后续基于 ScrollTrigger progress 的 scrub 动画,会“在你看到它之前”就跑完

这就是本次“下划线消失”的错觉来源。


两种方案:改上游布局 vs. 局部容错

方案 A(更“正统”,但影响范围大):修 pinSpacing / 手动 spacer

思路:

  • pinSpacing:false 改成 true,或手动添加等效 spacer
  • 让滚动条推进与视觉推进一致

优点:

  • 所有后续 ScrollTrigger 的 start/end 计算更符合直觉

缺点:

  • 影响布局/节奏,可能需要重新调多个 section 的视觉排版

方案 B(更平衡,影响范围小):Goals 文案使用真实视口位置驱动

思路:

  • Goals 文案不再依赖 ScrollTrigger 的 progress
  • 改用 getBoundingClientRect() 得到元素相对视口的位置
  • 把这个位置映射到 0..1 的 progress,然后写入:
    • style.color
    • style.backgroundSize(下划线长度)

实现要点:

  • requestAnimationFrame 合并 scroll 事件,避免频繁计算
  • passive: true 降低滚动阻塞

我选择了方案 B:它不要求重构上游 pinned section,并且对用户来说“看到什么就动什么”。


最终修复:用视口 progress 驱动颜色与下划线长度

对应实现位于:

  • src/components/goals-showcase/GoalsCardShowcase.tsx

核心代码(简化版):

const rect = el.getBoundingClientRect();
const startY = window.innerHeight * 0.85;
const endY = window.innerHeight * 0.45;
const progress = clamp01((startY - rect.top) / (startY - endY));
 
el.style.color = interpolatedColor(progress);
el.style.backgroundSize = `${progress * 100}% 3px`;

这样不管上游滚动条怎么被 pin “消耗”,只要元素还没真实进入视口,progress 就不会提前变成 1。


验证方式

  1. npm run dev
  2. 打开 http://localhost:3001/en?goalsDebug=1
  3. 滚动到 Goals 文案区域:
    • 文字应从灰变深
    • 下划线从 0% 拉到 100%

如果你还在用 markers 调试其它段落:记得这段文案现在不依赖 ScrollTrigger progress,因此 markers 不再是它的“真相来源”。


总结:pinSpacing=false 不是错,但它改变了“滚动”的语义

这次的教训不是“不要用 pinSpacing=false”,而是:

  • 当页面存在多个 pinned 段落,尤其有 pinSpacing:false 时,
  • scroll 值不再等同于“内容露出进度”

对于“必须跟随真实视口呈现”的小 UI(如文案划线),更稳的做法是:

  • 用视口几何(getBoundingClientRect())当作真相
  • 用 rAF 合并更新,保证性能

这类修复也更容易局部收敛,不需要牵一发动全身。

CleanLove

Written by CleanLove

Full-stack developer passionate about modern web technologies