准备工作
技术栈: vue + gsap
最近在做的一个动画,特意去网上查了一下叫 pinned 滚动叙事,感觉蛮有趣,分享给你们。
实现的动画如下:
对应的模仿网址是:www.vaporesso.com/series-prod…
思考过程🤔
多滚动几次上面的动画,感官上大致可以得出一个粗浅的感受。
页面滚到某一段时,画面突然停住了。你继续滚动,页面没有向下走,而是像一条时间线一样开始播放:卡片移动到中心、放大、图片和文字拉开距离,然后卡片退场,背后的内容一层一层覆盖进来。等这一段讲完,卡片又回到原位,下一个卡片接着进入页面中心,重复类似上一个卡片的动作。
从上面的动画中可以看出,实际上页面一直是固定在屏幕中间的,只是通过鼠标滚动,控制 GSAP 时间轴的播放进度。相信这对于了解GSAP的同学一定很熟悉。
既然大致的思路有了,接下来就是如何去构建页面结构了。
第一步:拆解DOM
根据上面的已知了解,我们可以将上面的动作拆分成两部分,第一部分是卡片,第二部分是详情内容面板
所以你大致可以得出这样的DOM
1<template> 2 <div class="container"> 3 <!-- 固定在屏幕中的舞台 --> 4 <div class="story-stage"> 5 <!-- 第一层:概览卡片轨道 --> 6 <div class="story-card"> 7 <article class="story-item"></article> 8 <article class="story-item"></article> 9 <article class="story-item"></article> 10 </div> 11 12 <!-- 第二层:详情内容面板 --> 13 <div class="story-panel"> 14 <section class="story-section"></section> 15 <section class="story-section"></section> 16 <section class="story-section"></section> 17 </div> 18 </div> 19 </div> 20</template> 21
这一步是确定层级关系:
- container:负责撑开滚动距离。
- story-stage:固定在视口中的舞台。
- story-card:三张卡片组成的轨道,后面会整体移动。
- story-panel:详情内容的叠放容器。
- story-section:每一个需要淡入淡出的详情楼层。
第二步:确定布局关系
既然DOM已经有了,接下来就是确定布局关系了,在第一步将DOM拆分成了卡片轨道、详情面板、固定舞台,这样就可以写出简化的css了。
1.container { 2 position: relative; 3 height: 1200vh; 4} 5 6.story-stage { 7 position: sticky; 8 top: 0; 9 height: 100vh; 10 overflow: hidden; 11} 12 13.story-card, 14.story-panel { 15 position: absolute; 16 inset: 0; 17} 18 19.story-card { 20 display: grid; 21 grid-template-columns: repeat(3, 1fr); 22 align-items: center; 23} 24 25.story-section { 26 position: absolute; 27 inset: 0; 28 opacity: 0; 29 visibility: hidden; 30} 31
这样一来,页面滚动时,真正被滚动条推进的是 container 的高度,而用户看到的画面始终停留在 story-stage 这一屏里。
后面的动画,其实都是在这一屏固定舞台里做状态切换。
第三步:准备数据结构
布局确定之后,就可以考虑数据结构了。
因为一张卡片对应一组详情内容,所以这里不适合把 DOM 写死。更好的方式是用一份 storyGroups 数据同时生成卡片和详情 section。
1const storyGroups= [ 2 { 3 id: 'signal', 4 kicker: 'Insight Layer', 5 title: '城市信号', 6 description: '从街区、交通和实时事件中提炼趋势。', 7 accent: '#35b7a8', 8 cardImage: 9 'https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=1200&q=80', 10 panels: [ 11 { 12 id: 'map', 13 kicker: 'Live Map', 14 title: '街区热度被压缩到一屏', 15 description: '用叠层图像表达空间变化,滚动只改变当前楼层透明度,不产生普通页面楼层。', 16 image: 17 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=1600&q=80', 18 stats: [ 19 { value: '12', label: '重点街区' }, 20 { value: '4.8x', label: '峰值变化' }, 21 ], 22 }, 23 { 24 id: 'motion', 25 kicker: 'Video', 26 title: '视频楼层进入时重新播放', 27 description: '当前视频只在对应内容完全进入时播放,离开或被下一层覆盖后立即暂停。', 28 image: 29 'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=1600&q=80', 30 video: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4', 31 }, 32 { 33 id: 'brief', 34 kicker: 'Decision', 35 title: '最后收束成行动摘要', 36 description: '同一组内容播放完成后,卡片回到初始位置,为下一组叙事让出节奏。', 37 image: 38 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1600&q=80', 39 stats: [ 40 { value: '82%', label: '信号命中' }, 41 { value: '18m', label: '刷新周期' }, 42 ], 43 }, 44 ], 45 }, 46 { 47 id: 'craft', 48 kicker: 'Product Layer', 49 title: '产品工艺', 50 description: '把材质、结构和细节拆成连续镜头。', 51 accent: '#e86d5b', 52 cardImage: 53 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=1200&q=80', 54 panels: [ 55 { 56 id: 'material', 57 kicker: 'Material', 58 title: '材质镜头先占据全屏', 59 description: '图片楼层绝对定位在 pinned 容器中,前后内容通过 scrub 时间轴交叉淡入淡出。', 60 image: 61 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1600&q=80', 62 stats: [ 63 { value: '0.6mm', label: '边缘精度' }, 64 { value: '32', label: '工艺步骤' }, 65 ], 66 }, 67 { 68 id: 'assembly', 69 kicker: 'Assembly', 70 title: '结构细节在原屏内切换', 71 description: '不是跳转,不是锚点,也不是后续普通楼层,而是同一个 timeline 的连续片段。', 72 image: 73 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=1600&q=80', 74 }, 75 { 76 id: 'quality', 77 kicker: 'Quality', 78 title: '质检数据随后覆盖进来', 79 description: '每组内容结束后,概览卡片重新出现、缩小并恢复横排状态。', 80 image: 81 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=1600&q=80', 82 stats: [ 83 { value: '99.2%', label: '通过率' }, 84 { value: '7', label: '关键检测' }, 85 ], 86 }, 87 ], 88 }, 89 { 90 id: 'future', 91 kicker: 'Experience Layer', 92 title: '未来体验', 93 description: '用滚动串联场景、情绪和最终状态。', 94 accent: '#d2a63f', 95 cardImage: 96 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&w=1200&q=80', 97 panels: [ 98 { 99 id: 'scene', 100 kicker: 'Scene', 101 title: '第一幕建立场景关系', 102 description: '卡片进入中心后放大,内部图像上移、文案下移,再把舞台交给内容楼层。', 103 image: 104 'https://images.unsplash.com/photo-1497366754035-f200968a6e72?auto=format&fit=crop&w=1600&q=80', 105 }, 106 { 107 id: 'loop', 108 kicker: 'Video', 109 title: '动态片段承接情绪峰值', 110 description: '视频显示时从头开始,继续滚动离开后暂停,回滚进入也会重新对齐播放状态。', 111 image: 112 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1600&q=80', 113 video: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4', 114 }, 115 { 116 id: 'finish', 117 kicker: 'Final', 118 title: '最后回到完整概览', 119 description: '第三组结束后粘性定位结束,页面才继续向下滚动。', 120 image: 121 'https://images.unsplash.com/photo-1494526585095-c41746248156?auto=format&fit=crop&w=1600&q=80', 122 stats: [ 123 { value: '3', label: '概览卡片' }, 124 { value: '9', label: '叠层内容' }, 125 ], 126 }, 127 ], 128 }, 129] 130 131
这样做的好处是,动画流程只需要写一套。后面如果要增加第四张卡片,只需要继续往数组里加数据。
第四步:计算轨道移动距离
这里有一个关键点:我需要移动的是整条卡片轨道,而不是单独移动某一张卡片。
所以当某张卡片要进入中心时,本质上是计算:
整条轨道需要移动多少,才能让当前卡片的中心点和舞台中心点重合。
1const getMoveX = targetCard => () => { 2 const stageRect = stageRef.value.getBoundingClientRect() 3 const cardRect = targetCard.getBoundingClientRect() 4 const stageCenter = stageRect.left + stageRect.width / 2 5 const cardCenter = cardRect.left + cardRect.width / 2 6 return stageCenter - cardCenter 7} 8
轨道需要移动的距离 = 舞台中心点 - 当前卡片中心点
如果结果是负数,说明卡片在舞台中心右侧,轨道需要往左移动;如果结果是正数,说明卡片在舞台中心左侧,轨道需要往右移动。
第五步:编排卡片动画
有了轨道移动距离之后,就可以开始编排单张卡片的动画了。
每一张卡片的动画都可以看成同一套流程:
- 找到当前卡片;
- 找到除当前卡片外的其他卡片;
- 移动整条轨道,让当前卡片进入中心;
- 其他卡片淡出;
- 当前卡片放大并淡出;
- 图片上移,文案下移;
- 整条轨道淡出,交给后面的详情 section。
1const addCardSequence = (timeline, group) => { 2 const cardNodes = getCardNodes(group.id) 3 if (!cardNodes) return 4 5 const { card, image, copy } = cardNodes 6 const cards = cardRef.value 7 const cardTrack = cardTrackRef.value 8 if (!cardTrack) return 9 10 const otherCards = cards.filter(item => item !== card) 11 const currentPanels = getPanelsByGroupId(group.id) 12 13 // 第一段:轨道整体移动到目标卡片居中,然后卡片放大淡出,交给详情面板。 14 timeline 15 .set(cards, { zIndex: 1 }) 16 .set(card, { zIndex: 3 }) 17 .set(cardTrack, { autoAlpha: 1 }) 18 .to(cardTrack, { x: getMoveX(card), duration: 0.85 }) 19 .to(otherCards, { opacity: 0, scale: 0.94, filter: 'saturate(0.55)', duration: 0.38 }, '<+=0.12') 20 .to(card, { opacity: 0, scale: 4, duration: 0.72 }, '<+=0.18') 21 .to(image, { y: -54, scale: 1.06, duration: 0.72 }, '<') 22 .to(copy, { y: 58, opacity: 0, duration: 0.58 }, '<') 23 .to(cardTrack, { autoAlpha: 0, duration: 0.28 }, '<+=0.35') 24 25 // 中间的内容 26 addPanelSequence(timeline, currentPanels, '<+=0.18') 27 28 // 第二段:详情面板结束后,先恢复当前卡片尺寸,再把整条轨道移回初始位置。 29 timeline 30 .to(cardTrack, { autoAlpha: 1, duration: 0.28 }, '>') 31 .to(card, { opacity: 1, scale: 1, duration: 0.75 }, '<') 32 .to(image, { y: 0, scale: 1, duration: 0.75 }, '<') 33 .to(copy, { y: 0, opacity: 1, duration: 0.65 }, '<') 34 .to(cardTrack, { x: 0, duration: 0.75 }, '>') 35 .to(otherCards, { opacity: 1, scale: 1, filter: 'saturate(1)', duration: 0.7 }, '<+=0.12') 36 .set(card, { zIndex: 1 }) 37} 38
这里卡片使用的是 opacity,而不是 autoAlpha。
因为 autoAlpha 会额外控制 visibility,在反向滚动时可能会让后面的卡片残留隐藏状态。单个卡片只需要透明度变化,用 opacity 更合适。
第六步:处理详情 section 切换
卡片退场后,背后的详情内容开始依次出现。
每个 section 的逻辑都一样:淡入、停留、淡出。
1const addPanelSequence = (timeline, panels) => { 2 panels.forEach((panel, index) => { 3 const previousPanel = panels[index - 1] 4 5 if (previousPanel) { 6 timeline.to(previousPanel, { autoAlpha: 0, duration: 0.45 }, '>') 7 } 8 9 timeline 10 .to(panel, { autoAlpha: 1, duration: 0.55 }, previousPanel ? '<' : '>') 11 .to({}, { duration: 0.75 }) 12 }) 13} 14
这里的 .to({}, { duration: 0.75 }) 是一个空动画。
它不改变任何 DOM,只是占用时间线长度。因为动画被滚动控制,所以它的作用就是给当前 section 留出阅读时间。
第七步:卡片恢复和轨道归位
详情内容播放完成后,不能直接把卡片重置回原始状态。
更自然的顺序应该是:先让当前卡片缩回原始大小,再让整条轨道回到初始位置。
1timeline 2 .to(cardTrack, { autoAlpha: 1, duration: 0.28 }, '>') 3 .to(card, { opacity: 1, scale: 1, duration: 0.75 }, '<') 4 .to(image, { y: 0, scale: 1 }, '<') 5 .to(copy, { y: 0, opacity: 1 }, '<') 6 .to(cardTrack, { x: 0, duration: 0.75 }, '>') 7 .to(otherCards, { opacity: 1, scale: 1, filter: 'saturate(1)' }, '<+=0.12') 8
这样用户能看到卡片从详情状态重新收回到概览状态,过渡会更完整。
第八步:绑定 ScrollTrigger
最后,把整条 timeline 交给 ScrollTrigger 控制。
1const timeline = gsap.timeline({ 2 defaults: { ease: 'power2.inOut' }, 3 scrollTrigger: { 4 trigger: containerRef.value, 5 start: 'top top', 6 end: 'bottom bottom', 7 scrub: 1, 8 invalidateOnRefresh: true, 9 }, 10}) 11
scrub: 1 表示滚动进度和动画进度绑定,并带一点缓冲。 invalidateOnRefresh: true 很重要,因为轨道移动距离是动态计算的,窗口尺寸变化后需要重新计算。
第九步:处理视频播放
如果详情 section 里只有图片,动画切换到这里基本就结束了。但如果某个 section 里放了视频,还需要额外处理播放状态。
原因很简单:视频不会因为它所在的 section 透明度变成 0 就自动暂停。如果不处理,可能会出现两个问题:
- section 已经淡出了,但视频还在后台继续播放;
- 用户反向滚动回来时,视频不是从头开始,而是从中间继续播放。
所以这里需要根据当前可见的 section,动态控制视频播放。
先定义一个变量记录当前正在播放的视频:
1let activeVideo = null 2
然后封装一个从头播放视频的方法:
1const playFromStart = video => { 2 try { 3 video.currentTime = 0 4 video.play().catch(() => undefined) 5 } catch { 6 video.play().catch(() => undefined) 7 } 8} 9
这里用了 try...catch 和 .catch(),是因为浏览器对自动播放有策略限制。虽然视频已经设置了 muted 和 playsinline,但 video.play() 仍然可能失败,所以这里做一个兜底,不让播放失败影响主动画。
接着封装暂停所有视频的方法:
1const pauseAllVideos = () => { 2 activeVideo = null 3 4 containerRef.value?.querySelectorAll('video').forEach(video => { 5 video.pause() 6 }) 7} 8
然后在初始化动画时,先找到所有视频,以及它们所在的 section:
1const videos = Array.from(containerRef.value.querySelectorAll('video')) 2 3const videoPanels = videos 4 .map(video => ({ 5 video, 6 panel: video.closest('.story-section'), 7 })) 8 .filter(item => item.panel) 9
有了这个映射关系之后,就可以在 timeline 更新时判断当前该播放哪个视频:
1const syncVideos = () => { 2 const visibleVideo = 3 videoPanels.find(({ panel }) => Number(gsap.getProperty(panel, 'opacity')) > 0.65) 4 ?.video ?? null 5 6 videos.forEach(video => { 7 if (video !== visibleVideo) { 8 video.pause() 9 } 10 }) 11 12 if (!visibleVideo || activeVideo === visibleVideo) { 13 if (!visibleVideo) activeVideo = null 14 return 15 } 16 17 activeVideo = visibleVideo 18 playFromStart(visibleVideo) 19} 20
这里的判断逻辑是:
- 如果某个 section 的透明度大于
0.65,就认为它已经进入可见状态; - 如果这个 section 里有视频,就把它作为当前视频;
- 其他视频全部暂停;
- 如果当前视频已经在播放,就不要重复
currentTime = 0,否则滚动过程中视频会一直被重置。
最后,把 syncVideos 挂到 timeline 的 onUpdate 上:
1const timeline = gsap.timeline({ 2 defaults: { ease: 'power2.inOut' }, 3 onUpdate: syncVideos, 4 scrollTrigger: { 5 trigger: containerRef.value, 6 start: 'top top', 7 end: 'bottom bottom', 8 scrub: 1, 9 invalidateOnRefresh: true, 10 onLeave: pauseAllVideos, 11 onLeaveBack: pauseAllVideos, 12 }, 13}) 14
这里的 onLeave 和 onLeaveBack 只做一件事:离开当前滚动动画区域时,暂停所有视频。
这样处理之后,视频就和 section 的显示状态绑定起来了。section 出现,视频从头播放;section 离开,视频暂停。
第十步:组件卸载时清理动画
最后一步是清理。
在 Vue 单页应用里,页面切换时组件会卸载。如果不清理 ScrollTrigger 和 GSAP 动画,下一次再进入页面时,可能会出现多个时间线同时存在、滚动触发重复、动画状态错乱等问题。
所以这里使用 gsap.context() 来管理当前组件里的动画:
1let animationContext = null 2 3onMounted(async () => { 4 await nextTick() 5 6 if (!isReady()) { 7 return 8 } 9 10 animationContext = gsap.context(() => { 11 // 初始化状态 12 gsap.set(cardTrackRef.value, { autoAlpha: 1, x: 0 }) 13 gsap.set(cardRef.value, { 14 opacity: 1, 15 scale: 1, 16 transformOrigin: 'center center', 17 }) 18 gsap.set(panelRef.value, { autoAlpha: 0 }) 19 20 // 创建 timeline 21 const timeline = gsap.timeline({ 22 defaults: { ease: 'power2.inOut' }, 23 onUpdate: syncVideos, 24 scrollTrigger: { 25 trigger: containerRef.value, 26 start: 'top top', 27 end: 'bottom bottom', 28 scrub: 1, 29 invalidateOnRefresh: true, 30 onLeave: pauseAllVideos, 31 onLeaveBack: pauseAllVideos, 32 }, 33 }) 34 35 storyGroups.forEach(group => addCardSequence(timeline, group)) 36 timeline.call(pauseAllVideos) 37 }, containerRef.value) 38 39 requestAnimationFrame(() => ScrollTrigger.refresh()) 40}) 41
gsap.context() 的好处是,它会记录在这个回调里创建的 GSAP 动画和 ScrollTrigger。等组件卸载时,只需要调用:
1onBeforeUnmount(() => { 2 pauseAllVideos() 3 animationContext?.revert() 4}) 5
这里做了两件事:
pauseAllVideos():暂停可能还在播放的视频;animationContext?.revert():还原当前组件中创建的动画和 ScrollTrigger。
这样页面切走后,动画不会残留。再次进入页面时,会重新创建一套干净的时间线。
完整代码
放在最后的话
虽然上面的代码有很大一部分不是我自己写的,而是借助 AI 生成后,再一步步拆开、调试和修改出来的,但这反而让我更确定一件事:在 AI 时代,代码能力不是变得不重要了,而是换了一种更实际的存在方式。
AI 可以很快给出一个看起来能跑的结果,甚至动画效果也足够惊艳。但当你真的把它放进自己的项目里,就会遇到很多需要判断和微调的地方。
为什么滚动高度要这样设置? 为什么卡片放大淡出后,要先恢复原大小,再移动整条轨道? 为什么 autoAlpha 会影响反向滚动时元素是否还能出现? 为什么视频播放要跟着详情 section 的可见状态同步? 为什么一个小小的时间点调整,就会影响整段动画的丝滑程度?
这些问题,AI 可以给你一个答案,但最终负责的人还是你。你需要看懂它生成的 DOM 结构、时间线顺序、状态切换和边界处理,才知道哪里能删、哪里不能动,哪里只是“看起来复杂”,哪里其实是在保证动画的稳定和流畅。
所以我越来越觉得,AI 并不是让我们不用写代码,而是把“写代码”的重心往前推了一层:从逐行敲实现,变成理解结构、验证逻辑、发现问题、精准微调。能不能看懂 AI 代码,决定了你能不能真正接管它;能不能微调细节,决定了这个结果能不能从 demo 变成项目里可用的东西。
关注我,一个在 AI 时代依旧认真读代码、拆代码、改代码的前端开发者。 我会持续分享前端实战、动画复刻、源码理解和 AI 辅助开发经验,希望我们都能借助 AI 跑得更快,也凭借代码基础走得更稳。
《如何使用GSAP实现一个
pinned滚动楼层叙事?》 是转载文章,点击查看原文。
