html部分(图片和视频自行替换)
js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>vivo官网</title>
<link rel="stylesheet" href="./css/index.css">
</head>
<body>
<!-- 第一屏 -->
<section class="screen section1">
<div class="kv-content">
<img src="./img/kv.webp" alt="">
</div>
</section>
<!-- 第二屏幕 -->
<section class="screen section2">
<div class="summary-content">
<video src="./media/summary.mp4" class="summary"></video>
<p class="text1">
流动山海纹¹ <br>
光影层叠,山海流淌。
</p>
<p class="text2">
东方灵韵 山海情
</p>
</div>
</section>
<!-- 第三屏幕 -->
<section class="screen section3">
<div class="color-img">
<img src="./img/color1.webp" alt="" class="color1">
<img src="./img/color2.webp" alt="" class="color2">
<img src="./img/color3.webp" alt="" class="color3">
<img src="./img/color4.webp" alt="" class="color4">
</div>
</section>
<!-- 第四屏动画 -->
<section class="screen section4">
<div class="parallel">
<div class="page1">
<p class="title">美学创作大师</p>
<video src="./media/video1.mp4" class="video1" muted></video>
<div class="info">
重塑美一秒 <br>
自在享受视频拍摄吧!理
想身材相机直出,让每一
刻的出镜更自由纯粹。
</div>
</div>
<div class="page2">
<video src="./media/video2.mp4" class="video2" muted></video>
<div class="info">
<h4>放手去拍,自动成片⁹</h4>
<p>Vlog 拍摄配套电影级滤镜,实时更新,免费</p>
</div>
</div>
</div>
</section>
<!-- 第五屏动画 -->
<!-- 第五屏动画 -->
<section class="screen section5">
<div class="rom-content">
<div class="rom-txt">
512GB
</div>
<div class="rom-img">
<img src="./img/1.webp" alt="" class="pic1">
<img src="./img/2.webp" alt="" class="pic2">
<img src="./img/3.webp" alt="" class="pic3">
<img src="./img/4.webp" alt="" class="pic4">
<img src="./img/5.webp" alt="" class="pic5">
<img src="./img/6.webp" alt="" class="pic6">
<img src="./img/7.webp" alt="" class="pic7">
</div>
</div>
</section>
<!-- js部分 -->
<script src="./js/gsap.min.js"></script>
<script src="./js/ScrollTrigger.min.js"></script>
<script src="./js/index.js"></script>
</body>
</html>
css部分
js
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
/* 计算方法 15 / (1920/100) */
font-size: 0.78125vw !important;
overflow-x: hidden;
}
body {
/* height: 50000px; */
background-color: #fafafa;
}
.screen {
position: relative;
width: 100%;
height: 100vh;
}
.kv-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100vh;
}
.kv-content img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 第二屏幕的样式 */
.summary-content {
position: absolute;
top: 0;
left: 50%;
width: 100%;
height: 100vh;
transform: translate(-50%, 0);
}
.summary-content video {
width: 100%;
height: 100%;
object-fit: cover;
}
.text1 {
position: absolute;
top: 60rem;
left: 21rem;
opacity: 0;
font-size: 4rem;
color: #fff;
}
.text2 {
position: absolute;
top: 29.266666666rem;
left: 29.333333333rem;
opacity: 0;
font-size: 8rem;
color: #fff;
}
/* 第三屏幕样式 */
.color-img {
width: 49em;
position: absolute;
top: 12em;
left: 40em;
}
.color1 {
width: 100%;
position: absolute;
top: 0;
left: 0;
margin-left: 90em;
}
.color2 {
width: 100%;
position: absolute;
top: 0;
left: 0;
margin-left: 100em;
scale: 1.1;
}
.color3 {
width: 100%;
position: absolute;
top: 0;
left: 0;
margin-left: 110em;
scale: 1.2;
}
.color4 {
width: 100%;
position: absolute;
top: 0;
left: 0;
margin-left: 120em;
scale: 1.3;
}
/* 第四屏动画 */
.parallel {
width: 100%;
height: 100%;
margin-top: 0;
position: relative;
overflow: hidden;
z-index: 50;
box-sizing: border-box;
}
.page1 {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
/* background-color: pink; */
}
.page2 {
display: flex;
position: absolute;
left: 0;
top: 0;
height: 100%;
width: 128em;
/* background-color: skyblue; */
}
.page1 .title {
width: 100%;
text-align: center;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: -webkit-linear-gradient(300deg, #4acbff 40%, #93ebff 47%, #ffd178 65%);
font-size: 8em;
font-family: HanyiVar-vivo-hwid-65;
color: #000000;
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
}
.page1 .video1 {
width: 20.3em;
position: absolute;
left: 40em;
top: 50%;
transform: translateY(-50%);
}
.page1 .info {
position: absolute;
left: calc(50% + 7em);
top: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
opacity: 0;
font-size: 1em;
font-family: HanyiVar-vivo-hwid-65;
color: #272727;
}
.page2 .video2 {
margin-left: 10em;
width: 68em;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.page2 .info {
padding-top: 45px;
margin-left: 10em;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
/* 第五屏动画 */
.rom-content {
width: 100%;
height: 100%;
position: relative;
}
.rom-txt {
width: 100%;
position: absolute;
top: 50%;
transform: translateY(-50%);
left: 0;
font-size: 20em;
text-align: center;
font-family: HanyiVar-vivo-hwid-65;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: -webkit-linear-gradient(110deg, #58b7f4 35%, #9ee6fa 55%);
background-size: 200% 100%;
background-position: left center;
}
.rom-img {
position: absolute;
width: 127em;
left: 0;
top: calc(50% + 45px);
transform: translateY(-50%);
}
.pic1,
.pic7 {
width: 11.266666666em;
position: absolute;
bottom: 5.26em;
}
.pic2,
.pic6 {
width: 13.066666666em;
position: absolute;
bottom: 3.17em;
}
.pic3,
.pic5 {
width: 14.866666666em;
position: absolute;
bottom: 1.06em;
}
.pic4 {
width: 18.75em;
bottom: 0;
margin-left: 54.125em;
}
.pic1,
.pic2,
.pic3 {
left: 55.125em;
z-index: -1;
}
.pic5,
.pic6,
.pic7 {
right: 55.125em;
z-index: -1;
}
.pic img {
position: absolute;
}
js部分
js
// 第一屏:图片缩放 + 视频展开
gsap.timeline({
scrollTrigger: {
trigger: ".section1",
start: "top top",
end: "+=1000",
scrub: true
}
})
.fromTo(".kv-content", { scale: 1 }, { scale: 0.5 })
.fromTo(".summary-content", { width: "50%", height: "50vh" }, { width: "100%", height: "100vh" }, "<");
// 第二屏:文字显隐 + 视频进度联动
gsap.timeline({
scrollTrigger: {
trigger: ".section2",
start: "top top",
end: "+=5000",
scrub: true,
pin: true,
onUpdate: (self) => {
// 播放视频
const summary = document.querySelector('.summary')
try {
// 视频的播放进度随着滚动条变化
// self.progress 整体进度 0~1
// summary.duration 视频的总时长
// console.log(self.progress)
summary.currentTime = self.progress * summary.duration
}
catch (e) {
console.log(e)
}
}
}
})
.to(".text1", { top: "20rem", opacity: 1 })
.to(".text1", { top: 0, opacity: 0 })
.to(".text2", { top: "27rem", opacity: 1 })
.to(".text2", { top: "24rem", opacity: 0 });
// 第三屏:色块流行动画(需保留独立 pin 固定)
ScrollTrigger.create({ trigger: ".section3", start: "top top", end: "+=1000", pin: true }); // 仅固定
gsap.timeline({
scrollTrigger: {
trigger: ".color-img",
start: "top-=500 top",
end: "+=3000",
scrub: true
}
})
.fromTo(".color1", { marginLeft: "90em", opacity: 0 }, { marginLeft: 0, opacity: 1 }, "<")
.fromTo(".color2", { marginLeft: "100em", scale: 1.3 }, { marginLeft: 0, scale: 1 }, "<")
.fromTo(".color3", { marginLeft: "110em", scale: 1.6 }, { marginLeft: 0, scale: 1 }, "<")
.fromTo(".color4", { marginLeft: "120em", scale: 1.9 }, { marginLeft: 0, scale: 1 }, "<")
.fromTo(".color1", { marginLeft: 0, opacity: 1 }, { marginLeft: "-120em", opacity: 1 }, ">")
.fromTo(".color2", { marginLeft: 0, scale: 1 }, { marginLeft: "-110em", scale: 1.3 }, "<")
.fromTo(".color3", { marginLeft: 0, scale: 1 }, { marginLeft: "-100em", scale: 1.6 }, "<")
.fromTo(".color4", { marginLeft: 0, scale: 1 }, { marginLeft: "-90em", scale: 1.9 }, "<");
// 第四屏:双页面切换 + 视频精准控制
ScrollTrigger.create({ trigger: ".section4", start: "top top", end: "+=3000", pin: true }); // 仅固定
gsap.timeline({
scrollTrigger: {
trigger: ".parallel",
start: "top top",
end: "+=3000",
scrub: true
}
})
.fromTo(".title", { opacity: 1 }, { opacity: 0 })
.fromTo(".video1", { marginTop: "100%" }, {
marginTop: 0,
onStart: () => {
const v = document.querySelector(".page1 .video1");
if (v) { v.currentTime = 0; v.play(); }
}
})
.fromTo(".info", { opacity: 0 }, { opacity: 1 })
.fromTo(".page1", { left: 0 }, { left: "-128em" }, ">")
.fromTo(".page2", { left: "128em" }, {
left: 0,
onStart: () => {
const v = document.querySelector(".page2 .video2");
if (v) { v.currentTime = 0; v.play(); }
}
}, "<");
// 第五屏:图片聚合动画
ScrollTrigger.create({ trigger: ".section5", start: "top top", end: "+=3000", pin: true }); // 仅固定
gsap.timeline({
scrollTrigger: {
trigger: ".rom-content",
start: "top+=500 top",
end: "+=2000",
scrub: true
}
})
.fromTo(".rom-txt", { opacity: 1, marginTop: 0 }, { opacity: 0, marginTop: "-7em" })
.fromTo(".pic4", { width: "18.75em" }, { width: "16.75em" })
.fromTo(".pic1", { left: "55.125em" }, { left: "14.3em" }, "<")
.fromTo(".pic7", { right: "55.125em" }, { right: "14.3em" }, "<")
.fromTo(".pic2", { left: "55.125em" }, { left: "26.1em" }, "<")
.fromTo(".pic6", { right: "55.125em" }, { right: "26.1em" }, "<")
.fromTo(".pic3", { left: "55.125em" }, { left: "39.8em" }, "<")
.fromTo(".pic5", { right: "55.125em" }, { right: "39.8em" }, "<");
这是一个非常典型且高质量的视差滚动案例,使用了业界顶尖的动画库 GSAP 及其插件 ScrollTrigger。
一、 JavaScript 部分详细解析
这段 JS 代码主要负责监听页面滚动,并将滚动进度映射为元素的动画进度。
1. 第一屏:图片缩放 + 视频展开
代码作用: 当用户向下滚动时,大图缩小,同时底部的视频容器从屏幕中间一小块区域展开至全屏,制造一种"拉开帷幕"的视觉效果。
javascript
// 创建一个 GSAP 时间轴,用于管理一连串的动画
gsap.timeline({
// 配置滚动触发器
scrollTrigger: {
trigger: ".section1", // 触发动画的元素是第一屏
start: "top top", // 当 .section1 的顶部碰到视口的顶部时开始
end: "+=1000", // 动画持续滚动 1000px 的距离
scrub: true // 关键属性:将动画进度与滚动条进度绑定,滚动条拖动则动画倒退/前进
}
})
// 动画1:图片缩小。从 scale: 1 到 scale: 0.5
.fromTo(".kv-content", { scale: 1 }, { scale: 0.5 })
// 动画2:视频容器展开。
// "<" 表示该动画与上一个动画同时开始(默认是等待上一个结束后才开始)
// 从宽50%高50vh 变为 宽100%高100vh(全屏)
.fromTo(".summary-content", { width: "50%", height: "50vh" }, { width: "100%", height: "100vh" }, "<");
2. 第二屏:文字显隐 + 视频进度联动
代码作用: 固定屏幕,随着滚动播放视频,并在特定时间点显示介绍文字。这是类似苹果官网的经典交互。
javascript
gsap.timeline({
scrollTrigger: {
trigger: ".section2",
start: "top top",
end: "+=5000", // 这里的滚动距离很长(5000px),意味着需要滚动很久,相当于把视频"拉长"了
scrub: true,
pin: true, // 关键属性:钉住当前屏幕,直到动画结束。这创造了"一镜到底"的效果
onUpdate: (self) => {
// 滚动更新时的回调函数
const summary = document.querySelector('.summary') // 获取 video 元素
try {
// 原理核心:利用滚动进度控制视频播放进度
// self.progress 是一个 0 到 1 的值,代表当前动画完成了百分之多少
// 视频当前时间 = 进度 * 总时长
summary.currentTime = self.progress * summary.duration
}
catch (e) {
console.log(e)
}
}
}
})
// 文字动画逻辑:文字从下方移入,停留,然后向上移出消失
.to(".text1", { top: "20rem", opacity: 1 }) // 移入
.to(".text1", { top: 0, opacity: 0 }) // 移出
.to(".text2", { top: "27rem", opacity: 1 }) // 第二段文字移入
.to(".text2", { top: "24rem", opacity: 0 }); // 第二段文字移出
3. 第三屏:色块流行动画
代码作用: 展示多张色彩图片,它们从屏幕右侧飞入,汇聚展示,然后再次飞出屏幕左侧。
javascript
// 仅仅用来固定屏幕,不做动画逻辑,为了让用户停留在这一屏看完动画
ScrollTrigger.create({ trigger: ".section3", start: "top top", end: "+=1000", pin: true });
gsap.timeline({
scrollTrigger: {
trigger: ".color-img",
start: "top-=500 top", // 提前500px开始,为了让动画衔接更自然
end: "+=3000",
scrub: true
}
})
// 入场动画:从右侧大距离移入
// "<" 符号表示所有图片同时开始移动,形成阵列感
.fromTo(".color1", { marginLeft: "90em", opacity: 0 }, { marginLeft: 0, opacity: 1 }, "<")
.fromTo(".color2", { marginLeft: "100em", scale: 1.3 }, { marginLeft: 0, scale: 1 }, "<")
// ... 省略部分类似代码
// 出场动画:向左侧飞出
// ">" 符号表示在上一个动画完成后执行,或者这里使用了 "<" 意味着同步开始?
// 代码中写的是 ">" 在第一个出场动画后,后续使用了 "<" 同步
.fromTo(".color1", { marginLeft: 0, opacity: 1 }, { marginLeft: "-120em", opacity: 1 }, ">")
.fromTo(".color2", { marginLeft: 0, scale: 1 }, { marginLeft: "-110em", scale: 1.3 }, "<");
// ... 省略部分类似代码
4. 第四屏:双页面切换 + 视频精准控制
代码作用: 类似幻灯片切换,第一页向左滑出,第二页从右侧滑入,并在切换时自动播放对应的视频。
javascript
ScrollTrigger.create({ trigger: ".section4", start: "top top", end: "+=3000", pin: true });
gsap.timeline({
scrollTrigger: {
trigger: ".parallel",
start: "top top",
end: "+=3000",
scrub: true
}
})
.fromTo(".title", { opacity: 1 }, { opacity: 0 }) // 标题淡出
.fromTo(".video1", { marginTop: "100%" }, {
marginTop: 0,
// onStart: 视频出现在屏幕时触发的回调
onStart: () => {
const v = document.querySelector(".page1 .video1");
if (v) { v.currentTime = 0; v.play(); } // 重置并播放视频
}
})
.fromTo(".info", { opacity: 0 }, { opacity: 1 })
// 页面滑动切换效果
.fromTo(".page1", { left: 0 }, { left: "-128em" }, ">") // 第一页移出视野
.fromTo(".page2", { left: "128em" }, {
left: 0, // 第二页移入视野
onStart: () => {
const v = document.querySelector(".page2 .video2");
if (v) { v.currentTime = 0; v.play(); } // 播放第二个视频
}
}, "<"); // "<" 确保 page1 和 page2 同时移动,形成无缝衔接
5. 第五屏:图片聚合动画
代码作用: 多张图片从散乱的位置向中心聚合,模拟"收纳"或"产品全家福"的展示效果。
javascript
ScrollTrigger.create({ trigger: ".section5", start: "top top", end: "+=3000", pin: true });
gsap.timeline({
scrollTrigger: {
trigger: ".rom-content",
start: "top+=500 top",
end: "+=2000",
scrub: true
}
})
.fromTo(".rom-txt", { opacity: 1, marginTop: 0 }, { opacity: 0, marginTop: "-7em" }) // 文字上移消失
.fromTo(".pic4", { width: "18.75em" }, { width: "16.75em" }) // 中心图片微调
// 图片位置移动,这里的 left/right 值变化实现了聚合效果
.fromTo(".pic1", { left: "55.125em" }, { left: "14.3em" }, "<")
.fromTo(".pic7", { right: "55.125em" }, { right: "14.3em" }, "<")
// ... 其他图片同理,通过改变 left/right 值向中间靠拢
二、 HTML 结构解析
HTML 结构非常清晰,采用了语义化标签 和模块化布局。
- 容器划分 (
section) : 每个<section>对应一个全屏的功能模块。class="screen"确保了每个模块最小高度为视口高度。 - 资源引用 :
<video>: 视频标签,注意muted属性(静音),因为现代浏览器策略不允许自动播放有声视频。<img>: 图片资源。
- 层级关系 : 例如第四屏,使用了
.parallel容器包裹.page1和.page2,这是为了实现水平滑动切换做铺垫,父容器需要有overflow: hidden和相对定位。
三、 CSS 样式解析
CSS 部分主要处理了布局基准和元素的初始状态。
-
REM 适配方案 :
csshtml { font-size: 0.78125vw !important; }原理 : 这是一个经典的移动端/PC端适配方案。
- 假设设计稿宽度为 1920px。
100vw = 1920px。- 如果我们希望
1rem = 15px(设计稿上的量度),那么1rem = 15/1920 * 100vw ≈ 0.78125vw。 - 这样,当屏幕变小时,
font-size变小,所有用rem做单位的元素都会等比缩放,实现响应式。
-
绝对定位 :
- 大量使用了
position: absolute。这是为了配合 GSAP 动画。因为 GSAP 经常需要控制top,left,margin等属性,绝对定位可以让元素脱离文档流,随意移动而不影响其他元素位置。
- 大量使用了
-
初始状态隐藏 :
- 很多元素(如
.text1,.text2)初始opacity: 0。 - GSAP 动画负责将它们显示出来。不要让 CSS 动画和 JS 动画冲突,初始状态交给 CSS 定义。
- 很多元素(如
四、 核心原理与设计思路
1. 为什么要用 scrub: true?
这是 ScrollTrigger 最核心的功能。普通的动画是播放完就结束了,而 scrub 将动画与滚动条绑定。
- 作用: 用户向前滚,动画播放;向后滚,动画倒退。这赋予了用户对时间的控制权,极大地增强了交互感。
2. 为什么要用 pin: true?
在长页面滚动中,如果内容不够长,用户滚动太快会错过动画。
- 作用 :
pin将当前部分"钉"在屏幕上,强制用户继续滚动来"消耗"设定的end距离(如+=5000),但页面看起来还停留在这一屏。这相当于延长了时间轴,让导演(开发者)有足够的时间展示细节。
3. 视频联动原理 (onUpdate)
视频播放通常是线性的(时间流逝)。要实现滚动控制视频,关键公式是: currentTime = progress * duration 这打破了视频原本的时间流速,让视频变成了一个可以被用户" scrubbing (擦洗)"的素材,而非被动播放的媒体。
五、 拓展知识点
位置参数 (< 和 >) : * 在 GSAP Timeline 中,控制动画时机非常关键。 * 默认是排队执行(一个接一个)。 * < 表示"在上一个动画开始时开始"。 * > 表示"在上一个动画结束时开始"。 * 熟练使用这两个符号,可以轻松编排复杂的并行/串行动画。
这里为什么要用两个固定?
js
ScrollTrigger.create({
trigger: ".section3",
start: "top top",
end: "+=1000",
pin: true
}); // 仅固定
gsap.timeline({
scrollTrigger: {
trigger: ".color-img",
start: "top-=500 top",
end: "+=3000",
scrub: true
}
})
实际上,这里并不是使用了"两个固定" ,而是将**"固定容器"和 "内部动画"这两个逻辑分离了。 让我详细拆解一下为什么要这么写,以及这段代码里隐藏的一个关键逻辑冲突**。
1. 为什么看起来像"写了两个"?
我们来看这两段代码的区别:
-
代码块 A(负责固定):
javascriptScrollTrigger.create({ trigger: ".section3", start: "top top", end: "+=1000", pin: true });- 作用 :它的唯一职责是把
.section3这个大容器"钉"在屏幕上,不随着页面滚动。 - 对象:外层容器。
- 作用 :它的唯一职责是把
-
代码块 B(负责动画):
javascriptgsap.timeline({ scrollTrigger: { trigger: ".color-img", // 注意:触发源变了,是内部的图片容器 start: "top-=500 top", end: "+=3000", scrub: true // 这里没有 pin: true } })- 作用:负责控制图片飞入飞出的动画逻辑。
- 对象:内部元素。
2. 为什么要拆开写?(核心原理)
如果合并在一起写,通常会有冲突。这里拆开的主要原因是为了实现**"入场动画提前"**的效果。 请看时间轴的对比:
- 固定的开始时间 :
start: "top top"- 意思是:当
.section3顶部碰到屏幕顶部时,开始固定。
- 意思是:当
- 动画的开始时间 :
start: "top-=500 top"- 意思是:当
.color-img距离屏幕顶部还有 500px 的时候,动画就开始了。 如果不拆开写: 如果你把pin: true写在gsap.timeline里,那么固定和动画会同时开始。用户必须先把屏幕滚到完全固定位置,图片才开始动,这样会显得很死板。 拆开写的效果:
- 意思是:当
- 用户向下滚动。
- 当图片距离顶部还有 500px 时,动画先开始(图片开始飞入)。
- 用户继续滚动,当 section 顶部到达屏幕顶部时,容器被固定住(pin 生效)。
- 用户继续滚动,动画继续进行。 结论: 拆开是为了让动画的触发点比固定的触发点更早,实现一种"先动起来,再停住展示"的流畅衔接感。
总结
- 不是两个固定:实际上是一个固定+ 一个动画控制。
- 为什么要拆:为了分离"固定触发点"和"动画触发点",实现更灵活的入场效果(动画比固定更早开始)。