GSAP ScrollTrigger 多个滚动动画组件相邻时,下方 top top 提前触发的问题排查与修复
先说结论
两个相邻滚动动画组件里,如果上方组件用了 pin,下方组件用了 start: 'top top',下方提前触发通常不是 top top 写错了,而是 ScrollTrigger 缓存位置时,上方 pin 还没有把滚动占位算进去。
也就是:
- 下方
HomeOwnerWarrantyCommitment先创建 ScrollTrigger,缓存了自己的start。 - 上方
HomeOwnerStorySteps后创建带pin的 ScrollTrigger。 pinSpacing把页面滚动距离撑长,后续组件真实位置变了。- 下方 trigger 没及时重新
refresh,还拿旧位置判断。 - 所以看起来像
start: 'top top'太早触发。
推荐修复方向:
- 上方带
pin的 ScrollTrigger 优先创建。 - 上方 pin trigger 设置更高
refreshPriority。 - 创建完上方 trigger 后执行
ScrollTrigger.sort()和ScrollTrigger.refresh()。 - 下方组件仍然可以保留
start: 'top top',不要用延迟显示、改 opacity 这类办法硬盖。 - resize、图片加载、异步内容变化后,做一次安全 refresh。
- 不必要时,不要把 CSS
sticky和 GSAPpin混在同一个区域里。
问题现象
页面里有两个相邻组件:
- 上方:
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 位置。
它的工作方式更像这样:
- 初始化时读取 trigger 的文档位置。
- 根据
start/end算出滚动区间。 - 后续滚动时主要看当前 scroll 值是否进入区间。
- 只有 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
}))
)
})然后做三件事:
- 首次进入页面,等图片加载完。
- resize 一次浏览器。
- 从页面顶部重新滚到两个组件区域。
如果下方 start 稳定,并且 marker 到达视口顶部时才触发,说明问题已经回到正确轨道。
最佳实践总结
- 页面有多个 ScrollTrigger 时,尤其上方有
pin,要关注初始化顺序。 - 会影响布局的 pinned section,应该先创建、先 refresh。
refreshPriority是解决复杂创建顺序的工具,不是每个 trigger 都要加。ScrollTrigger.sort()+ScrollTrigger.refresh(true)可以在组件化页面里做一次顺序兜底。invalidateOnRefresh适合尺寸、位移、图片加载、响应式变化较多的动画。- 下方组件不要用延迟显示、改 opacity、改 visibility 硬盖根因。
- 动画异常先查布局流、pinSpacing、start/end 缓存,再查 CSS 初始态。
- CSS
sticky和 GSAPpin能少混就少混。要混,也要明确边界。 - markers、临时 pin、调试日志排查后要删,不要把调试状态带进正式代码。