スクロールで物語る(Scroll Storytelling)
このページでは、GSAPのScrollTriggerで実装するscrub・pin・横スクロール・パララックスなど、スクロールに連動した高度なアニメーションパターンをデモとコードで確認できます。
position: sticky でもできますが、スクロール量に連動して進行度を制御すること(scrub によるタイムライン駆動・複数要素の速度差・横方向への変換)は、GSAPのScrollTriggerでなければ実装が難しい部分です。pin: true でセクションを固定するのが定石ですが、 ここでは ScrollTrigger の scroller オプションでそのコンテナを指定し、固定は CSS の position: sticky で行っています。 下のコードはこの構成のまま掲載しています。ページ全体をスクロールする構成で使う場合は、scroller の指定と sticky の枠組みを外し、pin: true と end: '+=…' による指定に置き換える必要があります。↓ 下にスクロールして6つのデモを体験してください。各セクションのコードは下部で確認できます。
読み込み中...
各デモのコード解説
各デモをクリックして実装コードを確認してください。すべてのデモは useGSAP({ scope: containerRef }) でスコープを設定し、アンマウント時に自動クリーンアップされます。
進捗バー(scrub)
スクロール量に連動して左から右へ伸びるプログレスバー。scrub: true でスクロール位置に即時追従します。
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger, useGSAP);
export default function ProgressBarDemo() {
const container = useRef<HTMLDivElement>(null);
useGSAP(() => {
// st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
gsap.to('.st-progress-bar', {
scaleX: 1,
ease: 'none',
scrollTrigger: {
trigger: '.st-progress-inner', // 高さを稼いだコンテンツ本体
scroller: container.current, // ページ全体ではなく、この枠内のスクロールに連動させる
start: 'top top',
end: 'bottom bottom',
scrub: true, // スクロール位置に即時追従
},
});
}, { scope: container });
return (
// 枠の中だけがスクロールする独立コンテナ
<div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
{/* バーは sticky で枠の上端に貼り付ける */}
<div style={{ position: 'sticky', top: 0, height: '6px', zIndex: 10 }}>
{/* 色や角丸などの見た目は省略 */}
<div className="st-progress-bar h-full origin-left" style={{ transform: 'scaleX(0)' }} />
</div>
<div className="st-progress-inner" style={{ height: '1200px' }}>
スクロールするコンテンツ
</div>
</div>
);
}テキスト演出(scrub)
複数行のテキストが1行ずつスクロールに連動してフェードイン。gsap.utils.toArray で一括設定できます。
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger, useGSAP);
export default function TextRevealDemo() {
const container = useRef<HTMLDivElement>(null);
useGSAP(() => {
// st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
// 初期値は gsap.set() で先に確定させる(HTML 側に style={{ opacity: 0 }} を書かずに済む)
gsap.set('.st-text-line', { opacity: 0, y: 60 });
// gsap.utils.toArray で複数要素を取得してまとめて設定
gsap.utils.toArray<HTMLElement>('.st-text-line').forEach((line) => {
gsap.to(line, {
opacity: 1,
y: 0,
scrollTrigger: {
trigger: line,
scroller: container.current, // ページ全体ではなく、この枠内のスクロールに連動させる
start: 'top 85%',
end: 'top 40%',
scrub: 1, // 数値指定で1秒の遅延をつけてなめらかに追従
},
});
});
}, { scope: container });
return (
// 枠の中だけがスクロールする独立コンテナ
<div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
{/* 高さ 1600px の余白がスクロール距離になる */}
<div style={{ height: '1600px' }}>
{/* sticky で枠の中央に貼り付ける */}
<div style={{ position: 'sticky', top: '50%', transform: 'translateY(-50%)' }}>
{['スクロールで', '物語を', '紡ぐ。'].map((text) => (
<p key={text} className="st-text-line">{text}</p>
))}
</div>
</div>
</div>
);
}画像ズーム(scrub)
スクロールに連動して画像が scale: 1 → 1.4 にゆっくりズーム。overflow: hidden で拡大をクリップします。
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger, useGSAP);
export default function ImageZoomDemo() {
const container = useRef<HTMLDivElement>(null);
useGSAP(() => {
// st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
gsap.to('.st-zoom-inner', {
scale: 1.4,
ease: 'none',
scrollTrigger: {
trigger: '.st-zoom-trigger', // 高さを稼いだ余白(=スクロール距離)
scroller: container.current, // ページ全体ではなく、この枠内のスクロールに連動させる
start: 'top top',
end: 'bottom bottom',
scrub: true,
},
});
}, { scope: container });
return (
// 枠の中だけがスクロールする独立コンテナ
<div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
{/* 高さ 1200px の余白がスクロール距離になる */}
<div className="st-zoom-trigger" style={{ height: '1200px' }}>
{/* sticky で 400px の表示窓を固定し、overflow: hidden で拡大分をクリップする */}
<div style={{ position: 'sticky', top: 0, height: '400px', overflow: 'hidden' }}>
{/* 背景画像などの見た目は省略 */}
<div className="st-zoom-inner" style={{ width: '100%', height: '100%' }} />
</div>
</div>
</div>
);
}パララックス
背景が前景より遅い速度で動くパララックス効果。yPercent で要素を縦方向に移動させます。
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger, useGSAP);
export default function ParallaxDemo() {
const container = useRef<HTMLDivElement>(null);
useGSAP(() => {
// st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
// 背景と前景で速度差をつけるため、ScrollTrigger を2本立てる
gsap.to('.st-parallax-bg', {
yPercent: -40, // 背景は大きく動かす(=遅く見える)
ease: 'none',
scrollTrigger: {
trigger: '.st-parallax-trigger',
scroller: container.current, // ページ全体ではなく、この枠内のスクロールに連動させる
start: 'top top',
end: 'bottom bottom',
scrub: true,
},
});
gsap.to('.st-parallax-fg', {
yPercent: -15, // 前景は控えめに動かす
ease: 'none',
scrollTrigger: {
trigger: '.st-parallax-trigger',
scroller: container.current,
start: 'top top',
end: 'bottom bottom',
scrub: true,
},
});
}, { scope: container });
return (
// 枠の中だけがスクロールする独立コンテナ
<div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
{/* 高さ 1200px の余白がスクロール距離になる */}
<div className="st-parallax-trigger" style={{ height: '1200px' }}>
{/* sticky で 400px の表示窓を固定する */}
<div style={{ position: 'sticky', top: 0, height: '400px', overflow: 'hidden' }}>
{/* 背景は 150% の高さを持たせ、上下に動かす余白を作る。見た目は省略 */}
<div
className="st-parallax-bg"
style={{ position: 'absolute', top: '-25%', left: 0, right: 0, height: '150%' }}
/>
<div className="st-parallax-fg" style={{ position: 'absolute', inset: 0 }}>
前景コンテンツ
</div>
</div>
</div>
</div>
);
}ピン留めコンテンツ切り替え
セクションをピン留めした状態で、スクロールに連動して中のスライドが順番に切り替わります。
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger, useGSAP);
const SLIDES = ['Step 1:始まり', 'Step 2:展開', 'Step 3:結末'];
export default function PinSwitchDemo() {
const container = useRef<HTMLDivElement>(null);
useGSAP(() => {
// st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
const slides = gsap.utils.toArray<HTMLElement>('.st-pin-slide');
// 2枚目以降を先に隠しておく(timeline の外で初期値を確定させる)
gsap.set(slides.slice(1), { autoAlpha: 0, y: 40 });
const tl = gsap.timeline({
scrollTrigger: {
trigger: '.st-pin-trigger',
scroller: container.current, // ページ全体ではなく、この枠内のスクロールに連動させる
start: 'top top',
end: 'bottom bottom', // 余白の分だけスクロールしたら終了
scrub: 1,
},
});
// 前のスライドを送り出し、同じタイミング('<')で次を迎え入れる
slides.forEach((slide, i) => {
if (i > 0) {
tl.to(slides[i - 1], { autoAlpha: 0, y: -40, duration: 1 });
tl.to(slide, { autoAlpha: 1, y: 0, duration: 1 }, '<');
}
});
}, { scope: container });
return (
// 枠の中だけがスクロールする独立コンテナ
<div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
{/* 高さ 1600px の余白がスクロール距離になる */}
<div className="st-pin-trigger" style={{ height: '1600px' }}>
{/* 固定は ScrollTrigger の pin ではなく CSS の position: sticky で行う */}
<div style={{ position: 'sticky', top: 0, height: '400px', overflow: 'hidden' }}>
{SLIDES.map((label) => (
<div key={label} className="st-pin-slide" style={{ position: 'absolute', inset: 0 }}>
{label}
</div>
))}
</div>
</div>
</div>
);
}
// autoAlpha は opacity と visibility をまとめて制御するGSAPの特殊プロパティ横スクロール演出
縦スクロールを横移動に変換。セクションをピン留めしてカードを横に流します。
'use client';
import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(ScrollTrigger, useGSAP);
const CARDS = ['Card A', 'Card B', 'Card C', 'Card D'];
export default function HorizontalScrollDemo() {
const container = useRef<HTMLDivElement>(null);
useGSAP(() => {
// st- は、複数のスクロール演出を同じページに置いてもセレクタが衝突しないための接頭辞
const scroller = container.current;
const track = scroller?.querySelector<HTMLElement>('.st-h-track');
if (!scroller || !track) return;
// 横にはみ出している量=トラックの実幅 − 表示幅。カード幅を決め打ちしないので
// カード枚数や幅が変わっても計算し直す必要がない
const totalShift = track.scrollWidth - scroller.offsetWidth;
gsap.to(track, {
x: -totalShift,
ease: 'none',
scrollTrigger: {
trigger: '.st-h-trigger',
scroller, // ページ全体ではなく、この枠内のスクロールに連動させる
start: 'top top',
end: 'bottom bottom',
scrub: 1,
},
});
}, { scope: container });
return (
// 枠の中だけがスクロールする独立コンテナ。overflowX: hidden で横のはみ出しを隠す
<div ref={container} style={{ height: '400px', overflowY: 'scroll', overflowX: 'hidden' }}>
{/* カード枚数 × 400px の余白がスクロール距離になる */}
<div className="st-h-trigger" style={{ height: `${400 * CARDS.length}px` }}>
{/* 固定は ScrollTrigger の pin ではなく CSS の position: sticky で行う */}
<div style={{ position: 'sticky', top: 0, height: '400px', overflow: 'hidden' }}>
<div
className="st-h-track"
style={{ display: 'flex', height: '100%', width: `${CARDS.length * 100}%` }}
>
{CARDS.map((label) => (
<div
key={label}
className="st-h-card"
style={{ width: `${100 / CARDS.length}%`, height: '100%', flexShrink: 0 }}
>
{label}
</div>
))}
</div>
</div>
</div>
</div>
);
}GSAP ScrollTrigger のポイント
- •
gsap.registerPlugin(ScrollTrigger, useGSAP)はコンポーネント外(モジュールレベル)で一度だけ呼ぶ - •
scrub: trueはスクロールに即時追従。scrub: 1は1秒の遅延でなめらかに追従 - •
scrollerでスクロール対象のコンテナを指定。省略するとページ全体のスクロールに連動する。ページ全体構成ならpin: trueでセクションを固定するのが定石だが、枠内スクロール構成では CSS のposition: stickyで代替する - •
ScrollTrigger.config({ ignoreMobileResize: true })でモバイルのアドレスバー伸縮による再計算を抑える - •
start / endは"top top"のように「要素の位置 ビューポートの位置」で指定。"+= 400"はピクセル数での相対指定 - •
useGSAP({ scope: containerRef } )でクリーンアップを自動化。アンマウント時にScrollTriggerも破棄される - •
autoAlphaは GSAP 独自のプロパティでopacityとvisibilityを連動させる
同じカテゴリの他のページ
文字を演出する(Text Animation)| UI Memo
GSAPのSplitText・ScrambleTextで実装する文字アニメーションのサンプル。1文字ずつフェード・スライド・波・スクランブル・バラけて再集合など、テキスト演出のUIパターン実例集。
動きの質感を選ぶ(Easing)
イージングの違いを体験で比較。power・back・bounce・elasticなどGSAP・Framer Motion・CSS Transitionのeaseを一覧・比較できるUIパターン実例集。
スクロールで見せる(Scroll Reveal)
スクロールで見せるアニメーションのサンプル。whileInView・IntersectionObserverを使ったUIパターン実例集。Framer MotionとIntersection Observer APIで実装比較。