关于 cinematic-gsap-lenis-motion-system
面向网页设计与开发者的 GSAP、ScrollTrigger 和 Lenis 动效指南,涵盖文字揭示、视差、固定场景及悬停交互。强调克制的电影感动效、性能和减少动态效果支持。
主要功能
- 01GSAP 与 Lenis 初始化规范
- 02文字与滚动揭示
- 03视差与固定滚动场景
- 04磁性悬停和自定义光标
- 05减少动态效果与性能建议
适用场景
- 01为作品集构建连贯的页面动效
- 02制作分段滚动叙事与视差
- 03为按钮和卡片添加悬停反馈
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ mengto/ skills --skill cinematic-gsap-lenis-motion-system安装后在 Claude Code 中输入/cinematic-gsap-lenis-motion-system确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
REFERENCES.mddemo/index.htmlPROMPT.md详细介绍
Cinematic GSAP Lenis 动效系统
适用场景
- 网站需要一套完整、高级的动效语言,而不是单个独立动画。
- 平滑滚动、滚动显现、固定场景、视差、悬停动效和光标行为需要彼此呼应。
- 目标风格是奢华编辑风、Apple 级别的精致感、创意工作室作品集,或沉浸式电影叙事。
- 技术栈可以使用 GSAP、ScrollTrigger 和 Lenis。
动效风格
- 平滑、优雅、略有延迟且富有目的性。
- 错落动效应引导阅读顺序。
- 分层运动应营造纵深感,同时避免界面显得繁杂。
- ScrollTrigger 应在场景进入视口时启动,而不是对每次轻微滚动都做出反应。
- 宁可含蓄,也不要过于强烈。
避免:
- 弹跳、弹性、弹簧式或俏皮的动效。
- 快速突兀的过渡。
- 大幅度的缩放跳变。
- 过度动画化的 UI。
- 炫目的游戏风格效果。
基础令牌
- 缓动:
power3.out、power4.out、expo.out。 - 滚动平滑度:使用
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("load", () => {
ScrollTrigger.refresh();
});
标记 API
使用简洁的数据属性,让动效系统能够在多个页面重复使用。
<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></div>
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;
}
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
[data-cursor] {
display: 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.textContent = part;
word.style.setProperty("--word-index", index);
mask.appendChild(word);
element.appendChild(mask);
index += 1;
});
element.dataset.motionSplit = "true";
}
function splitLines(element) {
if (element.dataset.motionLineSplit === "true") return;
if (element.querySelector(".motion-line")) return;
const text = (element.textContent || "").trim();
const lines = text.split(/\n+/).map((line) => line.trim()).filter(Boolean);
if (lines.length < 2) return;
element.textContent = "";
element.setAttribute("aria-label", text);
lines.forEach((line) => {
const mask = document.createElement("span");
const inner = document.createElement("span");
mask.className = "motion-line-mask";
mask.setAttribute("aria-hidden", "true");
inner.className = "motion-line";
inner.textContent = line;
mask.appendChild(inner);
element.appendChild(mask);
element.appendChild(document.createTextNode(" "));
});
element.dataset.motionLineSplit = "true";
}
function initTextReveals() {
if (reduceMotion) {
gsap.set("[data-motion-text]", { autoAlpha: 1, clearProps: "all" });
return;
}
gsap.utils.toArray("[data-motion-text='words']").forEach((element) => {
splitWords(element);
const words = element.querySelectorAll(".motion-word");
gsap.set(element, { autoAlpha: 1 });
gsap.fromTo(
words,
{ yPercent: 110, autoAlpha: 0, filter: "blur(8px)" },
{
yPercent: 0,
autoAlpha: 1,
filter: "blur(0px)",
duration: 0.9,
ease: "power4.out",
stagger: 0.055,
scrollTrigger: {
trigger: element,
start: "top 82%",
once: true,
},
}
);
});
gsap.utils.toArray("[data-motion-text='lines']").forEach((element) => {
splitLines(element);
const lines = element.querySelectorAll(".motion-line");
const targets = lines.length ? lines : element.children;
gsap.set(element, { autoAlpha: 1 });
gsap.fromTo(
targets,
{ yPercent: 100, autoAlpha: 0, filter: "blur(8px)" },
{
yPercent: 0,
autoAlpha: 1,
filter: "blur(0px)",
duration: 1,
ease: "power4.out",
stagger: 0.11,
scrollTrigger: {
trigger: element,
start: "top 84%",
once: true,
},
}
);
});
}
需要精确控制换行时的行标记:
<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: 1 } },
};
function initScrollReveals() {
if (reduceMotion) {
gsap.set("[data-reveal], [data-reveal-item]", { autoAlpha: 1, clearProps: "all" });
return;
}
gsap.utils.toArray("[data-reveal-group]").forEach((group) => {
const items = group.querySelectorAll("[data-reveal-item]");
gsap.set(group, { autoAlpha: 1 });
gsap.fromTo(
items,
{ y: 36, autoAlpha: 0, filter: "blur(8px)" },
{
y: 0,
autoAlpha: 1,
filter: "blur(0px)",
duration: 0.95,
ease: "power4.out",
stagger: 0.075,
scrollTrigger: {
trigger: group,
start: "top 82%",
once: true,
},
}
);
});
gsap.utils.toArray("[data-reveal]:not([data-reveal-item])").forEach((element) => {
const preset = revealPresets[element.dataset.reveal] || revealPresets["fade-up"];
gsap.set(element, { autoAlpha: 1 });
gsap.fromTo(element, preset.from, {
...preset.to,
duration: 0.9,
ease: "power4.out",
delay: Number(element.dataset.revealDelay || 0),
scrollTrigger: {
trigger: element,
start: "top 84%",
once: true,
},
});
});
}
图像裁剪显现
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: "power4.out" },
0
);
});
}
视差动效
利用速度差异,而非夸张的移动。背景移动速度应慢于内容;前景点缀的移动速度应略快。
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);
yTo(y);
});
element.addEventListener("pointerleave", () => {
xTo(0);
yTo(0);
});
});
}
悬停效果方案:
- 磁吸按钮:仅变换
x/y,缩放保持在1.03以下。 - 磁吸卡片:添加
rotateX/rotateY,角度控制在4deg以下。 - 图像缩放:从
scale: 1变为1.06,时长0.7s,缓动power3.out。 - 从灰度过渡到彩色:仅在小型或中型媒体上过渡滤镜。
- 动画箭头:将图标从
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.addEventListener("pointerenter", () => {
if (label) label.textContent = target.dataset.cursorLabel || "";
gsap.to(cursor, { scale: 1.75, duration: 0.35, ease: "power3.out" });
});
target.addEventListener("pointerleave", () => {
if (label) label.textContent = "";
gsap.to(cursor, { scale: 1, duration: 0.35, ease: "power3.out" });
});
});
}
鼠标响应式图层
每个区段只使用一个指针监听器。纵深效果应微乎其微。
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.left - rect.width / 2;
const y = event.clientY - rect.top - rect.height / 2;
setters.forEach(({ depth, xTo, yTo }) => {
xTo(x * depth);
yTo(y * depth);
});
});
section.addEventListener("pointerleave", () => {
setters.forEach(({ xTo, yTo }) => {
xTo(0);
yTo(0);
});
});
});
}
编排规则
- 首屏:先播放背景或媒体,再显示标题行,然后是辅助文案,最后是 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 后,文本和内容仍然可见。
- 为减少动态效果的用户提供静态内容,且不劫持平滑滚动。
- 滚动显示动画仅播放一次,除非设计明确要求重复播放。
- 固定定位区块不会与下一个区块重叠。
- 在触屏设备上禁用悬停和光标交互。
- 滚动期间不对布局属性添加动画。
- 移除所有装饰性动态效果后,页面仍然易于阅读。
评论
支持 Markdown