はじめに
長めのLPや特集ページでは、スクロールに合わせて内容の見せ方を少し工夫するだけで、読みやすさがかなり変わります。とはいえ、スクロール量の監視から要素の固定、進行に応じたアニメーション制御までを素のJavaScriptで組み立てると、意外と実装が重くなりがちです。
そんなときに扱いやすいのが GSAP と ScrollTrigger です。タイムラインで動きを整理しつつ、スクロール位置をそのままアニメーション進行に結び付けられるので、演出が増えてもコードを保ちやすくなります。
今回は、セクションを読み進めると画像とテキストが段階的に切り替わる「ストーリー型レイアウト」を題材に、基本の導入から応用までまとめます。
機能やライブラリの概要
GSAP は、JavaScript で UI アニメーションを組み立てやすくする定番ライブラリです。to() や fromTo() で単発の動きを書けるだけでなく、timeline() を使うと複数のアニメーションをひとつの流れとして管理できます。
その中でも ScrollTrigger は、スクロール位置をきっかけにアニメーションを開始したり、進行度に応じて動きを同期したりできるプラグインです。特に次のような場面で強みがあります。
- スクロール量に応じて要素を順番に表示する
- 特定のエリアを固定しながら内容だけ切り替える
- 進捗バーや章立てUIを連動させる
- 画面内に入ったタイミングで自然に演出を始める
CSS 単体のアニメーションよりも、「いつ始めるか」「どこまで進んだか」を JavaScript 側で管理しやすいのが実務での利点です。
インストール方法
まずは GSAP を追加します。ScrollTrigger は GSAP 本体に含まれているので、別パッケージの追加は不要です。
npm install gsap
pnpm add gsap
yarn add gsap
利用時は、GSAP 本体と ScrollTrigger を読み込んで登録します。
import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
gsap.registerPlugin(ScrollTrigger)
CDN 利用でも同じ考え方で、読み込み後に gsap.registerPlugin(ScrollTrigger) を実行すれば使えます。
基本の使い方
まずは「要素が画面内に入ったら、少し下からフェードアップする」基本形です。ScrollTrigger は、単発アニメーションの開始条件を付けるだけでもかなり便利です。
<section class="feature-list">
<article class="feature-card">表示速度を改善</article>
<article class="feature-card">問い合わせ導線を整理</article>
<article class="feature-card">更新しやすい構成に変更</article>
</section>
import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
gsap.registerPlugin(ScrollTrigger)
gsap.from(".feature-card", {
y: 24,
opacity: 0,
duration: 0.6,
stagger: 0.12,
ease: "power2.out",
scrollTrigger: {
trigger: ".feature-list",
start: "top 75%"
}
})
この形なら、スクロール監視のイベントを自前で持たずに導入できます。start を調整するだけで、どの位置から演出を始めるかを直感的に管理できるのも扱いやすいところです。
便利な使いどころ
ScrollTrigger は派手な表現だけでなく、情報整理のための演出にも向いています。特に相性が良いのは次のようなパターンです。
- 特集ページで章ごとにビジュアルを切り替える
- サービス紹介で説明文と図解を連動させる
- 採用ページでカルチャー紹介を段階的に見せる
- ダッシュボードでスクロール進行に合わせて注目ポイントを切り替える
共通しているのは、「読ませる順番を少し整えたい」ケースです。単純に動かすためではなく、視線誘導の補助として使うと効果が出やすいです。
応用コード
ここでは、左側のビジュアルを固定しつつ、右側のテキストを読み進めると表示中の画像と進捗バーが切り替わる例を作ります。LP や導入事例ページで使いやすい構成です。
See the Pen Untitled by watanabe (@web-sourcecode) on CodePen.
HTML
<section class="story" id="js-story">
<div class="story__visual">
<div class="story__screen is-active" data-scene="0">設計フェーズ</div>
<div class="story__screen" data-scene="1">実装フェーズ</div>
<div class="story__screen" data-scene="2">改善フェーズ</div>
<div class="story__progress"><span id="js-story-progress"></span></div>
</div>
<div class="story__content">
<section class="story__step" data-scene="0">
<h2>課題を整理する</h2>
<p>最初に改善ポイントを言語化し、UIの優先順位を固めます。</p>
</section>
<section class="story__step" data-scene="1">
<h2>画面に反映する</h2>
<p>主要導線から実装を進め、変更の影響範囲を小さく保ちます。</p>
</section>
<section class="story__step" data-scene="2">
<h2>数字を見て調整する</h2>
<p>公開後の反応を確認しながら、細かな改善を積み上げます。</p>
</section>
</div>
</section>
CSS
.story {
display: grid;
grid-template-columns: minmax(280px, 420px) 1fr;
gap: 32px;
}
.story__visual {
position: sticky;
top: 24px;
height: 320px;
border-radius: 20px;
overflow: hidden;
background: #0f172a;
color: #fff;
}
.story__screen {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
opacity: 0;
transform: scale(0.96);
}
.story__screen.is-active {
opacity: 1;
transform: scale(1);
}
.story__progress {
position: absolute;
left: 20px;
right: 20px;
bottom: 20px;
height: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 999px;
}
.story__progress span {
display: block;
width: 0%;
height: 100%;
border-radius: inherit;
background: #38bdf8;
}
.story__content {
display: grid;
gap: 48px;
}
.story__step {
min-height: 70vh;
}
JS
import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
gsap.registerPlugin(ScrollTrigger)
const story = document.querySelector("#js-story")
const steps = gsap.utils.toArray(".story__step")
const screens = gsap.utils.toArray(".story__screen")
const progressBar = document.querySelector("#js-story-progress")
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
function setActiveScene(index) {
screens.forEach((screen, screenIndex) => {
const isActive = screenIndex === index
screen.classList.toggle("is-active", isActive)
gsap.to(screen, {
opacity: isActive ? 1 : 0,
scale: isActive ? 1 : 0.96,
duration: prefersReduced ? 0.01 : 0.35,
ease: "power2.out",
overwrite: true
})
})
}
steps.forEach((step, index) => {
ScrollTrigger.create({
trigger: step,
start: "top center",
end: "bottom center",
onEnter: () => setActiveScene(index),
onEnterBack: () => setActiveScene(index)
})
})
gsap.to(progressBar, {
width: "100%",
ease: "none",
scrollTrigger: {
trigger: story,
start: "top top",
end: "bottom bottom",
scrub: true
}
})
ポイントは、見た目の切り替えを setActiveScene() にまとめていることです。ScrollTrigger 側は「どの章が現在アクティブか」を決める責務に寄せると、後で演出を差し替えやすくなります。
注意点
ScrollTrigger は便利ですが、導入時にいくつか気を付けたい点があります。
- 画面サイズによってスクロール量の感じ方が変わるので、
startとendは実機確認が必要 - 固定レイアウトは高さ不足だと窮屈に見えるため、モバイルでは構成を切り替えたほうが安全
- 画像やWebフォントの読み込み後にレイアウトが変わる場合は、必要に応じて
ScrollTrigger.refresh()を呼ぶ - 動きが多いUIでは
prefers-reduced-motionへの配慮を入れておく
特に実務では、演出そのものより「本文が読みやすいか」「操作を邪魔していないか」を優先したほうが失敗しにくいです。
まとめ
GSAP の ScrollTrigger を使うと、スクロールと連動する演出を JavaScript 側で整理しながら実装できます。単発のフェードアップから、固定ビジュアルを使ったストーリー型レイアウトまで、同じ考え方で段階的に拡張できるのが強みです。
スクロール演出は派手さに寄りすぎると逆効果ですが、視線誘導や情報整理のために使うとかなり実用的です。まずは 1 セクションだけでも導入して、読ませたい順番を丁寧に作るところから試すと扱いやすいと思います。
ポイント
- GSAP はタイムラインでアニメーションの流れを整理しやすい
- ScrollTrigger を使うとスクロール位置とアニメーション進行を結び付けやすい
- 単発演出より、章ごとの切り替えや進捗表示のようなUIで効果が出やすい
prefers-reduced-motionと実機確認を入れると実務導入しやすい