cinematic-gsap-lenis-motion-system について
GSAP、ScrollTrigger、Lenisを使うWebデザイナーや開発者向けのモーションガイドです。テキスト表示、視差、固定シーン、ホバー操作を扱い、控えめな演出や性能、動きを抑える設定も説明します。
主な機能
- 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詳細
シネマティック 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を無効にしても、テキストとコンテンツが表示されます。
- モーションを減らす設定を使用するユーザーには、静的コンテンツを表示し、スムーズスクロールによる操作の妨げをなくします。
- スクロール時の表示アニメーションは、デザインで再生が明示的に求められていない限り、一度だけ再生します。
- ピン留めしたセクションが次のセクションに重なりません。
- タッチ操作では、ホバーやカーソルとのインタラクションを無効にします。
- スクロール中にレイアウトプロパティをアニメーションさせません。
- 装飾的なモーションをすべて取り除いても、ページを読みやすく保ちます。