mcpField
C
cinematic-gsap-lenis-motion-system

cinematic-gsap-lenis-motion-system

github.com/mengto设计与 UI7.1k2.5k 次安装3 天前更新
代码仓库

关于 cinematic-gsap-lenis-motion-system

面向网页设计与开发者的 GSAP、ScrollTrigger 和 Lenis 动效指南,涵盖文字揭示、视差、固定场景及悬停交互。强调克制的电影感动效、性能和减少动态效果支持。

主要功能

  1. 01GSAP 与 Lenis 初始化规范
  2. 02文字与滚动揭示
  3. 03视差与固定滚动场景
  4. 04磁性悬停和自定义光标
  5. 05减少动态效果与性能建议

适用场景

  1. 01为作品集构建连贯的页面动效
  2. 02制作分段滚动叙事与视差
  3. 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.md
demo/
index.html
PROMPT.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