| name | cinematic-gsap-lenis-motion-system |
| description | 用 GSAP、ScrollTrigger 和 Lenis 打造高级电影感的网页动效系统。当需要奢华编辑风网站、创意工作室作品集、Awwwards 级别的交互、平滑滚动显现、错落文字、视差、固定区块、磁吸悬停态、自定义光标以及鼠标响应的分层运动时使用。 |
Cinematic GSAP Lenis Motion System
何时使用
- 网站需要一套完整的高级动效语言,而非某一个孤立的动画。
- 平滑滚动、滚动显现、固定场景、视差、悬停运动和光标行为应彼此关联、浑然一体。
- 目标质感是奢华编辑风、Apple 级别的精致、创意工作室作品集,或沉浸式电影感叙事。
- 技术栈可使用 GSAP、ScrollTrigger 和 Lenis。
动效审美
- 平滑、优雅、略带延迟、有意图。
- 错落运动应引导阅读顺序。
- 分层运动应营造纵深,又不让界面显得繁忙。
- ScrollTrigger 应在场景进入视口时启动,而非对每一次微小滚动都作出反应。
- 宁取微妙,不取强烈。
应避免:
- 弹跳、弹性、回弹或俏皮的运动。
- 快速突兀的过渡。
- 大幅度的缩放跳变。
- 过度动画化的 UI。
- 浮夸的游戏风特效。
基础令牌
- 缓动:
power3.out、power4.out、expo.out。
- 滚动 scrub:
scrub: 0.8 到 1.4,用于电影感的延迟。
- 显现:
0.75s 到 1.1s。
- 悬停:
0.35s 到 0.6s。
- 光标滞后:
0.25s 到 0.45s。
- 文字错落:单词
0.035s 到 0.07s,行 0.08s 到 0.14s。
- 卡片错落:
0.06s 到 0.1s。
- 显现触发:
start: "top 82%"。
- 固定交接:
anticipatePin: 1。
设置
安装:
npm i gsap lenis
在 DOM 存在之后初始化一次。Lenis 通过 GSAP ticker 驱动它的 RAF,从而让 ScrollTrigger 和平滑滚动保持同步。
import Lenis from "lenis";
import "lenis/dist/lenis.css";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
gsap.defaults({ ease: "power3.out", duration: 0.85 });
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let lenis;
if (!reduceMotion) {
lenis = new Lenis({
lerp: 0.08,
smoothWheel: true,
wheelMultiplier: 0.9,
anchors: true,
});
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);
}
window.addEventListener(, {
.();
});
标记 API
使用小巧的 data 属性,让动效系统可跨页面复用。
<h1 data-motion-text="lines">Digital products with cinematic restraint.</h1>
<p data-motion-text="words">Every interaction should feel deliberate.</p>
<section data-reveal-group>
<article data-reveal="fade-up" data-reveal-item>...</article>
<article data-reveal="fade-up" data-reveal-item>...</article>
</section>
<figure data-image-reveal data-parallax-section>
<img data-parallax-image src="/studio.jpg" alt="">
</figure>
<a data-magnetic data-cursor-label="Explore" href="/work">Explore</a>
<div data-cursor><span data-cursor-label></span>
CSS 基础
html.has-motion [data-motion-text],
html.has-motion [data-reveal],
html.has-motion [data-reveal-item],
html.has-motion [data-image-reveal] {
visibility: hidden;
}
.motion-line-mask,
.motion-word-mask {
display: inline-block;
overflow: hidden;
vertical-align: top;
}
.motion-line,
.motion-word {
display: inline-block;
will-change: transform, opacity, filter;
}
[data-image-reveal] {
overflow: hidden;
}
[data-parallax-image] {
display: block;
width: 100%;
height: 115%;
object-fit: cover;
will-change: transform;
}
[data-cursor] {
position: fixed;
left: 0;
top: 0;
z-index: 9999;
pointer-events: none;
mix-blend-mode: difference;
transform: translate3d(-50%, -50%, 0);
will-change: transform;
}
(: reduce), (: coarse) {
{
: none;
}
}
错落文字显现
对高级文字使用遮罩容器。当精确的换行位置重要时,优先使用手动的行包裹。对灵活的响应式文本使用单词切分。
document.documentElement.classList.add("has-motion");
function splitWords(element) {
if (element.dataset.motionSplit === "true") return;
const text = element.textContent || "";
const parts = text.split(/(\s+)/);
element.textContent = "";
element.setAttribute("aria-label", text.trim());
let index = 0;
parts.forEach((part) => {
if (!part.trim()) {
element.appendChild(document.createTextNode(part));
return;
}
const mask = document.createElement("span");
const word = document.createElement("span");
mask.className = "motion-word-mask";
mask.setAttribute("aria-hidden", "true");
word.className = "motion-word";
word. = part;
word..(, index);
mask.(word);
element.(mask);
index += ;
});
element.. = ;
}
() {
(element.. === ) ;
(element.()) ;
text = (element. || ).();
lines = text.().( line.()).();
(lines. < ) ;
element. = ;
element.(, text);
lines.( {
mask = .();
inner = .();
mask. = ;
mask.(, );
inner. = ;
inner. = line;
mask.(inner);
element.(mask);
element.(.());
});
element.. = ;
}
() {
(reduceMotion) {
gsap.(, { : , : });
;
}
gsap..().( {
(element);
words = element.();
gsap.(element, { : });
gsap.(
words,
{ : , : , : },
{
: ,
: ,
: ,
: ,
: ,
: ,
: {
: element,
: ,
: ,
},
}
);
});
gsap..().( {
(element);
lines = element.();
targets = lines. ? lines : element.;
gsap.(element, { : });
gsap.(
targets,
{ : , : , : },
{
: ,
: ,
: ,
: ,
: ,
: ,
: {
: element,
: ,
: ,
},
}
);
});
}
当精确的换行位置重要时的行标记:
<h2 data-motion-text="lines">
<span class="motion-line-mask"><span class="motion-line">Cinematic motion</span></span>
<span class="motion-line-mask"><span class="motion-line">with editorial restraint.</span></span>
</h2>
滚动显现
创建一个小巧的显现预设映射。使用 autoAlpha、变换和轻微模糊。在大元素上少用模糊。
const revealPresets = {
"fade-up": { from: { y: 32, autoAlpha: 0 }, to: { y: 0, autoAlpha: 1 } },
"blur-in": { from: { y: 18, autoAlpha: 0, filter: "blur(10px)" }, to: { y: 0, autoAlpha: 1, filter: "blur(0px)" } },
"scale": { from: { scale: 0.96, autoAlpha: 0 }, to: { scale: 1, autoAlpha: 1 } },
"slide-left": { from: { x: 48, autoAlpha: 0 }, to: { x: 0, autoAlpha: 1 } },
"slide-right": { from: { x: -48, autoAlpha: 0 }, to: { x: 0, autoAlpha: } },
};
() {
(reduceMotion) {
gsap.(, { : , : });
;
}
gsap..().( {
items = group.();
gsap.(group, { : });
gsap.(
items,
{ : , : , : },
{
: ,
: ,
: ,
: ,
: ,
: ,
: {
: group,
: ,
: ,
},
}
);
});
gsap..().( {
preset = revealPresets[element..] || revealPresets[];
gsap.(element, { : });
gsap.(element, preset., {
...preset.,
: ,
: ,
: (element.. || ),
: {
: element,
: ,
: ,
},
});
});
}
裁切式图像显现
function initImageReveals() {
if (reduceMotion) {
gsap.set("[data-image-reveal]", { autoAlpha: 1, clipPath: "none" });
return;
}
gsap.utils.toArray("[data-image-reveal]").forEach((figure) => {
const image = figure.querySelector("img");
gsap.set(figure, { autoAlpha: 1 });
const tl = gsap.timeline({
scrollTrigger: {
trigger: figure,
start: "top 82%",
once: true,
},
});
tl.fromTo(
figure,
{ clipPath: "inset(0 0 100% 0)" },
{ clipPath: "inset(0 0 0% 0)", duration: 1.1, ease: "power4.out" }
).fromTo(
image,
{ scale: 1.08, autoAlpha: 0.75 },
{ scale: 1, autoAlpha: 1, duration: 1.2, ease: },
);
});
}
视差运动
用速度差异代替夸张的移动。背景比内容移动更慢。前景强调元素移动稍快。
function initParallax() {
if (reduceMotion) return;
gsap.utils.toArray("[data-parallax-image], [data-parallax-layer]").forEach((layer) => {
const speed = Number(layer.dataset.parallaxSpeed || 0.18);
const section = layer.closest("[data-parallax-section]") || layer;
gsap.to(layer, {
y: () => window.innerHeight * speed * -1,
ease: "none",
scrollTrigger: {
trigger: section,
start: "top bottom",
end: "bottom top",
scrub: 1.2,
invalidateOnRefresh: true,
},
});
});
}
固定滚动区块
固定区块只用于叙事高潮时刻。让滚动同步的运动保持线性,然后在场景内部叠加带缓动的显现补间。
function initHorizontalGalleries() {
if (reduceMotion) return;
gsap.utils.toArray("[data-horizontal-gallery]").forEach((section) => {
const track = section.querySelector("[data-horizontal-track]");
if (!track) return;
gsap.to(track, {
x: () => -(track.scrollWidth - window.innerWidth),
ease: "none",
scrollTrigger: {
trigger: section,
start: "top top",
end: () => `+=${track.scrollWidth}`,
scrub: 1,
pin: true,
anticipatePin: 1,
invalidateOnRefresh: true,
},
});
});
}
粘性叙事模式:
function initStoryScenes() {
if (reduceMotion) return;
gsap.utils.toArray("[data-story-scene]").forEach((scene) => {
const panels = scene.querySelectorAll("[data-story-panel]");
gsap.timeline({
scrollTrigger: {
trigger: scene,
start: "top top",
end: () => `+=${panels.length * window.innerHeight}`,
scrub: 1.1,
pin: true,
anticipatePin: 1,
},
})
.to(panels, { yPercent: -100 * (panels.length - 1), ease: "none" })
.to(scene.querySelectorAll("[data-story-depth]"), { yPercent: -16, ease: "none" }, 0);
});
}
高级悬停交互
使用 GSAP quickTo 实现磁吸运动,让悬停跟随指针,而不必在每次事件时重新创建补间。
function initMagnetic() {
if (reduceMotion || window.matchMedia("(pointer: coarse)").matches) return;
gsap.utils.toArray("[data-magnetic]").forEach((element) => {
const strength = Number(element.dataset.magnetic || 0.18);
const xTo = gsap.quickTo(element, "x", { duration: 0.45, ease: "power3.out" });
const yTo = gsap.quickTo(element, "y", { duration: 0.45, ease: "power3.out" });
element.addEventListener("pointermove", (event) => {
const rect = element.getBoundingClientRect();
const x = (event.clientX - rect.left - rect.width / 2) * strength;
const y = (event.clientY - rect.top - rect.height / 2) * strength;
xTo(x);
(y);
});
element.(, {
();
();
});
});
}
悬停配方:
- 磁吸按钮:只平移
x/y,缩放保持在 1.03 以下。
- 磁吸卡片:加入
rotateX/rotateY,保持在 4deg 以下。
- 图像放大:
scale: 1 到 1.06,时长 0.7s,缓动 power3.out。
- 灰度转彩色:仅在中小尺寸媒体上过渡 filter。
- 动画箭头:将图标从
x: 0 移到 x: 6,淡入那个复制的箭头。
- 方向性悬停:计算指针进入的一侧,但让移动保持在
16px 以下。
自定义光标
把光标跟随体当作氛围而非装饰。在触摸设备上隐藏它。
function initCursor() {
if (reduceMotion || window.matchMedia("(pointer: coarse)").matches) return;
const cursor = document.querySelector("[data-cursor]");
if (!cursor) return;
const label = cursor.querySelector("[data-cursor-label]");
const xTo = gsap.quickTo(cursor, "x", { duration: 0.35, ease: "power3.out" });
const yTo = gsap.quickTo(cursor, "y", { duration: 0.35, ease: "power3.out" });
document.addEventListener("pointermove", (event) => {
xTo(event.clientX);
yTo(event.clientY);
});
gsap.utils.toArray("[data-cursor-label]")
.filter((target) => !cursor.contains(target))
.forEach((target) => {
target.(, {
(label) label. = target.. || ;
gsap.(cursor, { : , : , : });
});
target.(, {
(label) label. = ;
gsap.(cursor, { : , : , : });
});
});
}
鼠标响应层
每个区块使用一个指针监听器。纵深应几乎不可察觉。
function initMouseParallax() {
if (reduceMotion || window.matchMedia("(pointer: coarse)").matches) return;
gsap.utils.toArray("[data-mouse-parallax]").forEach((section) => {
const layers = section.querySelectorAll("[data-mouse-depth]");
const setters = Array.from(layers).map((layer) => ({
layer,
depth: Number(layer.dataset.mouseDepth || 0.04),
xTo: gsap.quickTo(layer, "x", { duration: 0.8, ease: "power3.out" }),
yTo: gsap.quickTo(layer, "y", { duration: 0.8, ease: "power3.out" }),
}));
section.addEventListener("pointermove", (event) => {
const rect = section.getBoundingClientRect();
const x = event.clientX - rect. - rect. / ;
y = event. - rect. - rect. / ;
setters.( {
(x * depth);
(y * depth);
});
});
section.(, {
setters.( {
();
();
});
});
});
}
编排准则
- 主视觉:背景或媒体先启动,标题行第二,辅助文案第三,CTA 最后。
- 区块:标签先,标题第二,媒体第三,卡片/细节最后。
- 固定场景:每个视口一个想法。避免同时堆叠过多的变换。
- 视差:背景更慢,前景稍快,文字大体稳定。
- 光标和悬停效果应支持导航意图,而非与之相争。
性能准则
- 只动画
transform、opacity 和短暂存在的 clip-path。
- 只在文字或小元素上使用
filter: blur()。
- 限制固定区块的数量,并在移动端测试它们。
- 只给真正会动画的元素加
will-change。
- 在图片、字体或布局变动之后使用
ScrollTrigger.refresh()。
- 在 React 或 SPA 路由中,把设置包在
gsap.context() 里,并在清理时调用 ctx.revert()。
- 在页面切换时,先杀掉或还原 ScrollTrigger,再初始化下一个路由。
初始化顺序
initTextReveals();
initScrollReveals();
initImageReveals();
initParallax();
initHorizontalGalleries();
initStoryScenes();
initMagnetic();
initCursor();
initMouseParallax();
ScrollTrigger.refresh();
QA 检查清单
- 禁用 JavaScript 时文字和内容仍可见。
- 减弱动效的用户获得静态内容,且不被平滑滚动劫持。
- 除非设计明确要求重放,否则滚动显现只动画一次。
- 固定区块不与下一个区块重叠。
- 悬停和光标交互在触摸设备上被禁用。
- 滚动过程中不动画任何布局属性。
- 即便移除所有装饰性动效,页面仍显得可读。