Skip to content

GSAP ScrollTrigger 多个滚动动画组件相邻时,下方 top top 提前触发的问题排查与修复

先说结论

两个相邻滚动动画组件里,如果上方组件用了 pin,下方组件用了 start: 'top top',下方提前触发通常不是 top top 写错了,而是 ScrollTrigger 缓存位置时,上方 pin 还没有把滚动占位算进去。

也就是:

  1. 下方 HomeOwnerWarrantyCommitment 先创建 ScrollTrigger,缓存了自己的 start
  2. 上方 HomeOwnerStorySteps 后创建带 pin 的 ScrollTrigger。
  3. pinSpacing 把页面滚动距离撑长,后续组件真实位置变了。
  4. 下方 trigger 没及时重新 refresh,还拿旧位置判断。
  5. 所以看起来像 start: 'top top' 太早触发。

推荐修复方向:

  • 上方带 pin 的 ScrollTrigger 优先创建。
  • 上方 pin trigger 设置更高 refreshPriority
  • 创建完上方 trigger 后执行 ScrollTrigger.sort()ScrollTrigger.refresh()
  • 下方组件仍然可以保留 start: 'top top',不要用延迟显示、改 opacity 这类办法硬盖。
  • resize、图片加载、异步内容变化后,做一次安全 refresh。
  • 不必要时,不要把 CSS sticky 和 GSAP pin 混在同一个区域里。

问题现象

页面里有两个相邻组件:

  • 上方:HomeOwnerStorySteps,用 GSAP ScrollTrigger 做横向滚动,并用 pin 固定内部 stage。
  • 下方:HomeOwnerWarrantyCommitment,用 ScrollTrigger,start: 'top top',预期组件顶部滚到视口顶部后,才播放图片缩放、标题移动、底部 card 出现动画。

实际现象是:

  • 上方横向滚动还没走完。
  • 最后一项还没进入最终状态。
  • 下方 HomeOwnerWarrantyCommitment 的动画已经完成。
  • 底部 card 初始状态被提前改掉,看起来像一开始就显示了。

这时开发者很容易先怀疑下方组件:

js
scrollTrigger: {
  trigger: sectionRef.value,
  start: 'top top',
  toggleActions: 'play none none reverse'
}

top top 的语义没有问题。它的意思就是:trigger 元素顶部碰到视口顶部时触发。

真正的问题是:ScrollTrigger 判断“元素顶部在哪里”时,拿到的是旧坐标。

为什么改 opacity、visibility、触发阈值不能根治

常见临时修法有三种:

css
.card {
  opacity: 0;
  visibility: hidden;
}

或者:

js
start: 'top 80%'
start: 'top 90%'
start: 'top bottom'

再或者给下方动画加延迟:

js
delay: 0.3

这些都只能改变“提前触发后看起来怎样”,不能改变“为什么提前触发”。

因为根因不在动画初始态,而在 trigger 的几何位置缓存。下方 trigger 已经认为自己到达了 top top,所以它会正常推进 timeline。你把 card 设成隐藏,只是让提前播放没那么明显;你把 start 改低,只是把错误坐标上的触发点往后挪一点。

一旦屏幕高度、图片加载时间、组件 mounted 顺序、上方 pin 距离变了,问题还会回来。

原理解释:ScrollTrigger 不是每帧重新算布局

ScrollTrigger 为了性能,不会在每一帧滚动时都重新读取所有 DOM 位置。

它的工作方式更像这样:

  1. 初始化时读取 trigger 的文档位置。
  2. 根据 start / end 算出滚动区间。
  3. 后续滚动时主要看当前 scroll 值是否进入区间。
  4. 只有 refresh 时才重新计算 start / end

这点很重要。

如果初始化后页面布局变化了,但没有 refresh,ScrollTrigger 可能继续用旧位置。

pin 会改变后续组件的位置

pin 不是普通动画。

当你这样写:

js
ScrollTrigger.create({
  trigger: section,
  pin: stage,
  start: 'top top',
  end: '+=2400',
  scrub: true
})

ScrollTrigger 会在 pin 区间内固定元素。默认情况下,pinSpacing 会生效,ScrollTrigger 会给页面补一段空间,让后续内容等 pin 结束后再接上。

换句话说,上方 pin section 会影响下方组件的文档位置。

如果下方 trigger 已经提前算过位置,后面上方才补 pin spacing,下方缓存就过期了。

初始化顺序为什么会乱

在 Vue / Nuxt 里,组件看起来是从上到下写的,但 ScrollTrigger 创建顺序未必稳定。

原因包括:

  • 子组件各自 onMounted
  • 某个组件里用了 nextTick
  • 图片加载后才计算宽度或高度。
  • 上方横向滚动需要等 DOM 宽度、图片、字体稳定后才创建。
  • 下方组件逻辑更简单,反而先创建 trigger。

于是会出现这种顺序:

txt
HomeOwnerWarrantyCommitment mounted
  -> 创建下方 trigger
  -> 缓存 start

HomeOwnerStorySteps mounted + nextTick + 图片/宽度计算
  -> 创建上方 pin trigger
  -> pinSpacing 生效
  -> 页面后续位置变化

下方 trigger 没 refresh
  -> 继续使用旧 start

这就是下方 top top 看起来提前触发的常见路径。

CSS sticky 和 GSAP pin 混用为什么更乱

position: sticky 和 ScrollTrigger pin 都会制造“元素看起来固定”的效果,但它们控制的层不同。

  • sticky 由浏览器布局引擎处理,元素仍在正常文档流附近。
  • pin 由 ScrollTrigger 接管,会临时包裹、固定、还原,并用 pinSpacing 影响后续内容。

如果同一区域既有 sticky,又有 pin,排查时很容易看错:

  • 视觉上元素没动,不代表文档流没变。
  • sticky 的吸顶点,不等于 ScrollTrigger 的 start。
  • pin refresh 时会临时 revert pinned 元素,再重新计算。
  • 浏览器 sticky 和 GSAP pin 的“固定”不是同一个系统。

所以,排查 ScrollTrigger 位置问题时,先减少混用。能用 pin 解决,就别再给同一层加 sticky;确实要混用,也要明确谁控制布局,谁只做内部视觉。

根因定位

这个问题可以归纳成一句话:

上方 pinned trigger 后创建,导致下方 trigger 先缓存了没有 pinSpacing 的旧位置。

对应到页面:

  • HomeOwnerStorySteps 使用 pin 后,会影响后面 HomeOwnerWarrantyCommitment 的文档位置。
  • HomeOwnerStorySteps 的 trigger 因为 nextTick、图片加载或宽度计算延迟创建。
  • HomeOwnerWarrantyCommitment 更早创建,先把自己的 start 算好了。
  • 上方 pinSpacing 生效后,后续内容被推远。
  • 下方 trigger 没按正确顺序 refresh,于是它的 start 还是旧值。

所以不是下方 start: 'top top' 本身错。

错的是刷新顺序。

推荐修复方案

1. 让上方带 pin 的 trigger 优先创建

如果一个页面里有多个 ScrollTrigger,优先创建影响布局的 trigger,尤其是带 pin 的 section。

原则:

  • 上方 pinned section 先创建。
  • 普通 reveal、fade、scale trigger 后创建。
  • 如果组件拆散了,用 refreshPriority 和统一 refresh 保底。

2. 给上方 pin trigger 设置 refreshPriority

refreshPriority 用来影响 refresh 顺序。数字越大,越早刷新。

上方 pinned trigger 可以给高一点:

js
refreshPriority: 10

这样 refresh 时,它会优先把 pin 距离算进去,后面的 trigger 再基于新的页面位置计算。

3. 创建上方 trigger 后 sort + refresh

当组件 mounted 顺序不可控时,创建完上方 pin trigger 后手动:

js
ScrollTrigger.sort()
ScrollTrigger.refresh()

sort() 让 ScrollTrigger 按 refreshPriority 和位置重新排序。

refresh() 让所有 trigger 重新计算 start/end。

如果刚改完 DOM,或者图片刚加载完,可以用安全 refresh:

js
ScrollTrigger.refresh(true)

它会等浏览器有机会完成渲染后再刷新,减少读取到半成品布局的概率。

4. 下方组件也可以 refresh,但核心是上游 pin 先占位

下方 HomeOwnerWarrantyCommitment 初始化完再 refresh() 有帮助,但它不是核心。

核心是:上方 pin 必须先在 refresh 顺序里把 pinSpacing 算进去。

否则下方自己 refresh 时,还是可能踩在上方 pin 尚未稳定的布局上。

5. 使用 invalidateOnRefresh

如果动画里有动态尺寸、响应式位移、图片尺寸、横向滚动距离,建议加:

js
invalidateOnRefresh: true

这样 refresh 时,相关动画会重新读取起始值,避免 resize 后继续用旧值。

6. 清掉调试 pin、markers

排查阶段可以用:

js
markers: true

但正式代码不要留下调试 marker,也不要为了“看起来正常”临时加一个无意义 pin。调试代码会继续影响布局和 refresh 顺序,后面更难查。

示例代码:上方 HomeOwnerStorySteps

下面是 Vue 3 script setup 示例。重点不在横向动画本身,而在 pinned trigger 的创建顺序、刷新顺序和清理。

vue
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'

gsap.registerPlugin(ScrollTrigger)

const sectionRef = ref(null)
const stageRef = ref(null)
const trackRef = ref(null)

let storyTimeline = null
let storyTrigger = null
let resizeTimer = null

function getHorizontalDistance() {
  const stage = stageRef.value
  const track = trackRef.value

  if (!stage || !track) return 0

  return Math.max(0, track.scrollWidth - stage.clientWidth)
}

function refreshScrollTriggerSoon() {
  window.clearTimeout(resizeTimer)

  resizeTimer = window.setTimeout(() => {
    // resize 后布局可能还在抖,safe refresh 避免读到半成品尺寸。
    ScrollTrigger.refresh(true)
  }, 120)
}

async function initStoryStepsScroll() {
  await nextTick()

  const section = sectionRef.value
  const stage = stageRef.value
  const track = trackRef.value

  if (!section || !stage || !track) return

  const distance = getHorizontalDistance()

  storyTimeline = gsap.timeline({
    defaults: {
      ease: 'none'
    },
    scrollTrigger: {
      id: 'home-owner-story-steps-pin',
      trigger: section,
      start: 'top top',
      end: () => `+=${Math.max(distance, window.innerHeight)}`,
      scrub: true,
      pin: stage,
      pinSpacing: true,
      anticipatePin: 1,
      invalidateOnRefresh: true,
      refreshPriority: 10,
      // markers: true, // 只在排查时打开,正式代码删掉。
      onRefresh(self) {
        console.log('[story refresh]', {
          start: self.start,
          end: self.end,
          progress: self.progress
        })
      }
    }
  })

  storyTimeline.to(track, {
    x: () => -getHorizontalDistance()
  })

  storyTrigger = storyTimeline.scrollTrigger

  // pinned trigger 会影响后续组件位置,创建后立刻重排并刷新全部 trigger。
  ScrollTrigger.sort()
  ScrollTrigger.refresh(true)
}

onMounted(() => {
  initStoryStepsScroll()
  window.addEventListener('resize', refreshScrollTriggerSoon)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', refreshScrollTriggerSoon)
  window.clearTimeout(resizeTimer)

  if (storyTrigger) {
    storyTrigger.kill()
    storyTrigger = null
  }

  if (storyTimeline) {
    storyTimeline.kill()
    storyTimeline = null
  }
})
</script>

<template>
  <section ref="sectionRef" class="home-owner-story-steps">
    <div ref="stageRef" class="home-owner-story-steps__stage">
      <div ref="trackRef" class="home-owner-story-steps__track">
        <slot />
      </div>
    </div>
  </section>
</template>

这里有几个关键点:

  • refreshPriority: 10:让上方 pinned trigger 更早参与 refresh。
  • end: () => ...:用函数,refresh 时重新算横向滚动距离。
  • invalidateOnRefresh: true:resize 或 refresh 后重算动画起始值。
  • ScrollTrigger.sort():按优先级和位置重排。
  • ScrollTrigger.refresh(true):让全部 trigger 基于当前 DOM 重新算。
  • kill():组件卸载时清理,避免路由切换后旧 trigger 还在。

示例代码:下方 HomeOwnerWarrantyCommitment

下方组件不需要把 start: 'top top' 改成奇怪阈值。修正上游 pin 顺序后,它可以保持直觉写法。

vue
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'

gsap.registerPlugin(ScrollTrigger)

const sectionRef = ref(null)
const imageRef = ref(null)
const titleRef = ref(null)
const cardRef = ref(null)

let warrantyTimeline = null
let warrantyTrigger = null

async function initWarrantyScroll() {
  await nextTick()

  const section = sectionRef.value
  const image = imageRef.value
  const title = titleRef.value
  const card = cardRef.value

  if (!section || !image || !title || !card) return

  gsap.set(card, {
    autoAlpha: 0,
    y: 48
  })

  warrantyTimeline = gsap.timeline({
    scrollTrigger: {
      id: 'home-owner-warranty-commitment',
      trigger: section,
      start: 'top top',
      end: '+=100%',
      toggleActions: 'play none none reverse',
      invalidateOnRefresh: true,
      // markers: true, // 排查完删除。
      onRefresh(self) {
        console.log('[warranty refresh]', {
          start: self.start,
          end: self.end,
          progress: self.progress
        })
      },
      onUpdate(self) {
        console.log('[warranty update]', self.progress)
      }
    }
  })

  warrantyTimeline
    .fromTo(
      image,
      { scale: 1.12 },
      { scale: 1, duration: 0.8, ease: 'power2.out' }
    )
    .fromTo(
      title,
      { y: 60, autoAlpha: 0 },
      { y: 0, autoAlpha: 1, duration: 0.6, ease: 'power2.out' },
      '<'
    )
    .to(
      card,
      { y: 0, autoAlpha: 1, duration: 0.55, ease: 'power2.out' },
      '-=0.2'
    )

  warrantyTrigger = warrantyTimeline.scrollTrigger

  // 下方也刷新一次可以兜底,但关键仍是上方 pin 已先占位。
  ScrollTrigger.sort()
  ScrollTrigger.refresh(true)
}

onMounted(() => {
  initWarrantyScroll()
})

onBeforeUnmount(() => {
  if (warrantyTrigger) {
    warrantyTrigger.kill()
    warrantyTrigger = null
  }

  if (warrantyTimeline) {
    warrantyTimeline.kill()
    warrantyTimeline = null
  }
})
</script>

<template>
  <section ref="sectionRef" class="home-owner-warranty-commitment">
    <div class="home-owner-warranty-commitment__media">
      <img ref="imageRef" src="/images/home-owner/warranty.jpg" alt="" />
    </div>

    <h2 ref="titleRef" class="home-owner-warranty-commitment__title">
      Warranty commitment
    </h2>

    <div ref="cardRef" class="home-owner-warranty-commitment__card">
      <slot />
    </div>
  </section>
</template>

注意:如果是 Nuxt SSR 页面,并且这段代码不是只在客户端组件里执行,需要确保 gsap / ScrollTrigger 只在客户端初始化。最直接方式是放进 onMounted 内动态 import,或者让组件只在客户端渲染。上面为了突出 ScrollTrigger 顺序,示例使用静态 import。

如果要在 Nuxt 里动态 import

Nuxt 项目里更稳的写法是把 GSAP import 放进客户端 mounted:

js
let gsapInstance = null
let ScrollTriggerInstance = null

async function ensureGsap() {
  if (gsapInstance && ScrollTriggerInstance) {
    return {
      gsap: gsapInstance,
      ScrollTrigger: ScrollTriggerInstance
    }
  }

  const gsapModule = await import('gsap')
  const scrollTriggerModule = await import('gsap/ScrollTrigger')

  gsapInstance = gsapModule.gsap || gsapModule.default
  ScrollTriggerInstance = scrollTriggerModule.ScrollTrigger
  gsapInstance.registerPlugin(ScrollTriggerInstance)

  return {
    gsap: gsapInstance,
    ScrollTrigger: ScrollTriggerInstance
  }
}

然后在 initStoryStepsScroll 里:

js
const { gsap, ScrollTrigger } = await ensureGsap()

这能避开 SSR 阶段访问 window、DOM、插件注册带来的问题。

调试方法

1. 用 markers 看 start/end 是否提前

先给两个 trigger 都打开 markers:

js
markers: true

观察:

  • 下方 start 标记是不是在组件真正顶部到达视口顶部之前就经过了。
  • 上方 pinned section 的 start / end 是否覆盖了预期滚动距离。
  • 下方 marker 是否在 refresh 前后跳动。

如果 refresh 后 marker 位置明显改变,说明之前缓存位置确实过期。

2. 打印 self.start、self.end、progress

在两个 trigger 上加:

js
onRefresh(self) {
  console.log(self.vars.id, {
    start: self.start,
    end: self.end,
    progress: self.progress
  })
},
onUpdate(self) {
  console.log(self.vars.id, self.progress)
}

重点看:

  • 下方 start 是否小于预期。
  • 上方 pin 创建前后,下方 start 是否变化。
  • 下方 progress 是否在还没进入视觉区域时就已经到 1

如果下方 progress 已经到 1,card 提前显示就是正常结果,不是 CSS 初始态失效。

3. 判断是不是 pinSpacing 造成后续位置变化

临时对比:

js
pinSpacing: false

如果关掉后下方触发位置明显变化,就说明上方 pin spacing 正在影响页面滚动距离。

但这不是推荐修法。pinSpacing: false 会让后续内容可能顶上来、重叠,只适合验证原因。

更好的验证方式是打印下方组件位置:

js
console.log(
  sectionRef.value.getBoundingClientRect().top + window.scrollY
)

分别在:

  • 下方 trigger 创建时
  • 上方 pin trigger 创建后
  • ScrollTrigger.refresh()

各打印一次。如果数值变化,就说明文档位置被上方 pin 改了。

4. 用 ScrollTrigger.getAll() 查看创建顺序

js
console.table(
  ScrollTrigger.getAll().map((trigger, index) => ({
    index,
    id: trigger.vars.id,
    start: trigger.start,
    end: trigger.end,
    refreshPriority: trigger.vars.refreshPriority || 0,
    pin: Boolean(trigger.pin)
  }))
)

如果你看到:

txt
0 home-owner-warranty-commitment
1 home-owner-story-steps-pin

就要警惕。下方 trigger 先创建,上方 pinned trigger 后创建,正是这个问题的高发顺序。

调用:

js
ScrollTrigger.sort()
ScrollTrigger.refresh(true)

再看一次表格。正常情况下,上方 pinned trigger 应该更早刷新,下方 start/end 应该基于 pin 后的位置。

5. 确认 refresh 后位置是否正确

用这段监听全局 refresh:

js
ScrollTrigger.addEventListener('refresh', () => {
  console.table(
    ScrollTrigger.getAll().map((trigger) => ({
      id: trigger.vars.id,
      start: trigger.start,
      end: trigger.end,
      progress: trigger.progress
    }))
  )
})

然后做三件事:

  1. 首次进入页面,等图片加载完。
  2. resize 一次浏览器。
  3. 从页面顶部重新滚到两个组件区域。

如果下方 start 稳定,并且 marker 到达视口顶部时才触发,说明问题已经回到正确轨道。

最佳实践总结

  1. 页面有多个 ScrollTrigger 时,尤其上方有 pin,要关注初始化顺序。
  2. 会影响布局的 pinned section,应该先创建、先 refresh。
  3. refreshPriority 是解决复杂创建顺序的工具,不是每个 trigger 都要加。
  4. ScrollTrigger.sort() + ScrollTrigger.refresh(true) 可以在组件化页面里做一次顺序兜底。
  5. invalidateOnRefresh 适合尺寸、位移、图片加载、响应式变化较多的动画。
  6. 下方组件不要用延迟显示、改 opacity、改 visibility 硬盖根因。
  7. 动画异常先查布局流、pinSpacing、start/end 缓存,再查 CSS 初始态。
  8. CSS sticky 和 GSAP pin 能少混就少混。要混,也要明确边界。
  9. markers、临时 pin、调试日志排查后要删,不要把调试状态带进正式代码。

参考资料

基于 VitePress 的个人知识库骨架