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を使うWebデザイナーや開発者向けのモーションガイドです。テキスト表示、視差、固定シーン、ホバー操作を扱い、控えめな演出や性能、動きを抑える設定も説明します。

主な機能

  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

詳細

シネマティック 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 のティッカーを通じて 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、duration は 0.7s、ease は 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" });
      });
    });
}

マウスに反応するレイヤー

セクションごとにポインターリスナーを 1 つ使います。奥行きの変化は、かろうじて見える程度にします。

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 を開始します。
  • セクション:まずラベル、次に見出し、その次にメディア、最後にカードや詳細を表示します。
  • ピン留めシーン:1 つのビューポートにつき、アイデアは 1 つにします。同時に動かすトランスフォームを重ねすぎないようにします。
  • 視差効果:背景はゆっくり、前景は少し速く、テキストはほぼ固定します。
  • カーソルとホバーのエフェクトは、ナビゲーションの意図を支えるものにし、邪魔しないようにします。

パフォーマンスルール

  • 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を無効にしても、テキストとコンテンツが表示されます。
  • モーションを減らす設定を使用するユーザーには、静的コンテンツを表示し、スムーズスクロールによる操作の妨げをなくします。
  • スクロール時の表示アニメーションは、デザインで再生が明示的に求められていない限り、一度だけ再生します。
  • ピン留めしたセクションが次のセクションに重なりません。
  • タッチ操作では、ホバーやカーソルとのインタラクションを無効にします。
  • スクロール中にレイアウトプロパティをアニメーションさせません。
  • 装飾的なモーションをすべて取り除いても、ページを読みやすく保ちます。

コメント