GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

スクロール トリガー アニメーション 9選 — GSAPなしのバニラJSで

スクロール トリガー アニメーションとは、画面をスクロールして要素がビューポートに入った瞬間に動き始める仕組みのことです。GSAPなし、IntersectionObserver ひとつで9つのエンジンを手組みし、下のグリッドが今動いています。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並びは人気順ではなく、エンジンが組み上がる順です。まず観察者本体が立ち(01)、しきい値を測る目盛りが付き(02)、エンジンを最初に使う側として「一度だけか毎回か」(03)とカウンター(04)が加わります。ここから先は視覚表現が画面を使い始めます — 単語が順に流れ(05)、方向(06)と速度(07)を察知し、層が分かれて奥行きが生まれ(08)、最後にセクションが画面に吸い付きます(09)。観察者という部品は、CSS スクロール アニメーション 9種とCSS パララックス スクロール 9種が JS ゼロで担っていた領域を、反対側から埋める軸です — あの2つはブラウザに任せ、この記事では自分でエンジンを回します。読書位置の表示はスクロール インジケーター 9種が、シーンのピン留めはスクロール ストーリーテリング セクション 9種がそれぞれ別に扱います。

01トリガーエンジンの核

画面に入ったかを見張って is-visible クラスを付けるエンジン本体で、出現方向4種・しきい値・一度だけを data-* 属性として HTML 側に書き、先読み余白だけは観察者の rootMargin で指定します。

トリガーエンジンの核
新しいタブで開く

01 · トリガーエンジンの核 — 計測 累積面積 20.597% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

IntersectionObserverdata-thresholdis-visible

観察者ひと組が何を読むか

function attach(card) {
  var threshold = parseFloat(card.getAttribute('data-threshold')) || 0;
  var once = card.getAttribute('data-once') === 'true';
  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (entry.isIntersecting) {
        entry.target.classList.add('is-visible');
        if (once) io.unobserve(entry.target);
      } else if (!once) {
        entry.target.classList.remove('is-visible');
      }
    });
  }, { root: view, rootMargin: '0px 0px -8px 0px', threshold: threshold });
  io.observe(card);
  observers.push(io);
}

root をページではなくスクロール領域に指定するのが最初の行の違いで、下辺の -8px は要素がビューポート最下部に掛かったときはまだ点灯せず、8px 進んでから点灯させるための値なので実ページでは -10% のようにパーセントで書くのが普通です。

CSS が知るのはクラスひとつ

.te__card {
  opacity: 0;
  transition: opacity $dur-enter $easing, transform $dur-enter $easing;
}
.te__card[data-scroll="up"] { transform: translateY(18px); }
.te__card[data-scroll="left"] { transform: translateX(-24px); }
.te__card.is-visible {
  opacity: 1;
  transform: none;
}

data-scroll の値ごとに始まりの位置は違っても終わりは transform: none ひとつで同じになり、瞬間は観察者、見た目は transition という二層構造が、css アニメーション スクロール したら 系の出現すべてを回しています。

02しきい値ゲージ

要素が画面にどれだけ入ったかを intersectionRatio の 0〜1 ゲージと数字で実測して見せ、しきい値候補を変えると点灯の瞬間が変わります。

しきい値ゲージ
新しいタブで開く

02 · しきい値ゲージ — 計測 累積面積 20.001% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

intersectionRatiothreshold: [0, 0.25, 0.5, 1]scaleX

値は階段でしか来ない

function paint(ratio, isIn) {
  numEl.textContent = ratio.toFixed(2);
  fill.style.transform = 'scaleX(' + ratio + ')';
  var on = isIn && ratio >= selected - 0.01;
  led.classList.toggle('is-on', on);
  stateEl.textContent = on ? 'しきい値に到達' : '待機中';
}
var io = new IntersectionObserver(function (entries) {
  entries.forEach(function (entry) {
    paint(entry.intersectionRatio, entry.isIntersecting);
  });
}, { root: view, threshold: [0, 0.25, 0.5, 1] });
io.observe(target);

観察者はしきい値を横切るたびにコールバックを呼ぶので値は連続ではなく階段で更新され、実測でも数字は 0.00 → 0.07 → 0.25(点灯) → 0.61 → 1.00 の5段に区切って変わり、ゲージの scaleX は常に数字と同じ値を描きました。

threshold 配列がそのままコールバック地図

threshold: [0, 0.25, 0.5, 1] は比率がこの4値を横切るたびに知らせる注文なので、候補を 0.25 から 1 に変えると同じ動きでも scrollTop 92 まで来てようやく点灯しました — ビューポートより高いセクションは比率が 1 に届かないため、高い要素にしきい値 1 は使えません。

03一度だけ vs 毎回

同じ出現アニメーションを片側は unobserve でちょうど一度だけ再生し、もう片側は通るたびに再生し直します。

一度だけ vs 毎回
新しいタブで開く

03 · 一度だけ vs 毎回 — 計測 累積面積 23.665% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

unobserveoffsetWidthdata-once

リフローのトリックでアニメーションを蘇らせる

var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (entry.isIntersecting) {
        card.classList.remove('is-in');
        void card.offsetWidth; // リフローのトリック — ここで強制再計算が起きるから、次の付与が新しいアニメーションになる
        card.classList.add('is-in');
        plays += 1;
        dots.forEach(function (d, i) { d.classList.toggle('is-lit', i < Math.min(plays, 3)); });
        if (once) io.unobserve(card); // 左 — ちょうど一度再生して観察を止める
      } else if (!once) {
        card.classList.remove('is-in'); // 右 — はずれると初期化し、次の進入でまた再生される
      }
    });
  }, { root: view, threshold: 0.5 });

一度付いたクラスを外して付け直してもブラウザは同じスタイル変更とみなして transition を再生し直さないので、その間に void card.offsetWidth で強制再計算を一度起こし、次の付与を真新しいアニメーションにします。

3往復を数えると

実測では3往復のあと左の再生は1回、右は3回で、transitionstart イベントで数えると 1 対 6 — 右は進入と退出が毎往復2回ずつ transition を始めるためです。

04カウンタートリガー

ビューポートに入った瞬間に requestAnimationFrame で数字を 0 から目標まで上げ、終わったら観察を止めます。

カウンタートリガー
新しいタブで開く

04 · カウンタートリガー — 計測 累積面積 13.421% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFrameeaseOutCubicunobserve

requestAnimationFrame が秒を測る

function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
// 観察コールバックの中で:
      io.unobserve(entry.target); // ここで観察を止める — 上に戻っても二度は数えない
      var t0 = null;
      function tick(ts) {
        if (t0 === null) t0 = ts;
        var p = Math.min((ts - t0) / MS, 1);
        var eased = easeOutCubic(p);
        numEl.textContent = String(Math.round(TARGET * eased));
        bar.style.transform = 'scaleX(' + eased + ')';
        if (p < 1) requestAnimationFrame(tick);
        else done.classList.add('is-shown');
      }
      requestAnimationFrame(tick);

時間を setInterval ではなくフレームのタイムスタンプ ts の差で測るのが要点で、タブがバックグラウンドで休むとフレームも時計も一緒に休むため数字が飛びません。

減速曲線を乗せる理由

実測でカウンターは 0 → 42 → 92 → 138 → 248 と単調に増えて easeOutCubic の減速曲線を描き、上に戻って再進入しても 248 のままでした — 直線のままだと終わりが急停止に見えます。

05タイムラインシーケンス

文を単語に分割し、各単語が順番に応じた transition-delay を受け取るようにすると、一度のトリガーでタイムラインのように流れます。

タイムラインシーケンス
新しいタブで開く

05 · タイムラインシーケンス — 計測 累積面積 27.501% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

transition-delayvar(--i)data-stagger

単語への分割は6行

var words = line.textContent.trim().split(/\s+/);
line.textContent = '';
words.forEach(function (word, i) {
  var span = document.createElement('span');
  span.className = 'sw__word';
  span.textContent = word;
  span.style.setProperty('--i', i);
  line.appendChild(span);
  if (i < words.length - 1) line.appendChild(document.createTextNode(' '));
});

JS は各単語に順番 --i を書き込むだけで、ずらしをかける他の方法が知りたければCSS 順次アニメーション 9種が同じ系統の隣の記事です。

遅延は CSS の計算式ひとつで

.sw__word {
  display: inline-block;
  opacity: 0;
  transform: translateY(14px);
  transition-property: opacity, transform;
  transition-duration: $dur-quick, 420ms;
  transition-timing-function: ease-out, $easing;
  transition-delay: calc(var(--i) * var(--stagger, 90ms)), calc(var(--i) * var(--stagger, 90ms));
}
.sw__card.is-visible .sw__word {
  opacity: 1;
  transform: none;
}

単語が6つなら実測で遅延は 0 / 0.09 / 0.18 / 0.27 / 0.36 / 0.45 秒の階段に収まり、遅延は CSS、瞬間はエンジンが担う分業がそのまま数字に現れました。

06方向検出の出入り

直前のスクロール位置と比べて上向きか下向きかを判定し、入るときと出るときで違う見た目にします。

方向検出の出入り
新しいタブで開く

06 · 方向検出の出入り — 計測 累積面積 34.152% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

isIntersectingscrollTopis-exit

方向は2行の比較

function readDir() {
  // 直前のスクロール位置と比べて進行方向を決める — 下へ行けば down、上へ行けば up
  var y = view.scrollTop;
  if (y > lastY) curDir = 'down';
  else if (y < lastY) curDir = 'up';
  root.setAttribute('data-dir', curDir);
  lastY = y;
}

観察コールバックは isIntersecting が変わるたびにその時点の curDir をカードに記録し、実測では下へスクロールするとカードが is-visible を受けて根に is-enter が付き、画面をはずれると is-exit と is-leave に変わり、上へ戻すと方向 up が印されて下りてきたときと違う登場になりました。

退出は一度入ってからだけ

最初にページを開いた時点で画面の外にある要素が退出として記録されるのを防ぐため、退出の分岐は「一度でも入った後」だけ動く met フラグを立てています。

07速度反応

フレーム間の移動量からスクロール速度を測り、速いほど行が傾いて広がるよう --vel に渡します。

速度反応
新しいタブで開く

07 · 速度反応 — 計測 累積面積 25.483% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

skewYclamp()--vel

速度は正規化して渡す

function tick() {
  var y = view.scrollTop;
  var dy = Math.abs(y - lastY);
  lastY = y;
  // 移動量を 0〜1 に正規化して余裕を持って追う — 急変せず、止まると減衰して 0 に戻る
  var target = Math.min(dy / 26, 1);
  vel += (target - vel) * 0.16;
  if (vel < 0.004) vel = 0;
  root.style.setProperty('--vel', vel.toFixed(4));
  requestAnimationFrame(tick);
}

実測では速いスクロールが --vel 0.98 で傾き成分 0.105、遅いスクロールが 0.099 で 0.012 となり、ホイールを離すと値は 0 に減衰して行は元の姿に戻りました。

CSS はその値を角度で使う

@property --vel {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
.vl__line:nth-child(odd) {
  transform: skewY(clamp(-6deg, calc(var(--vel, 0) * -7deg), 6deg)) translateY(calc(var(--vel, 0) * -4px));
}

@property で --vel を数値として登録して初めて CSS 関数の中で計算され、奇数行と偶数行で符号が逆なので、速くスクロールするほど行が互いに寄りかかって傾きます。

083層パララックス

遠い星・中景の丘・手前の文字がそれぞれ別の係数で translate3d により動き、毎フレームの lerp で目標へ滑らかに追従するため速いスクロールでもつまずきません。

3層パララックス
新しいタブで開く

08 · 3層パララックス — 計測 累積面積 20.681% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translate3dlerpwill-change

係数は HTML が持つ

<div class="px__layer px__layer--stars" data-rate="0.12" aria-hidden="true"></div>
<div class="px__layer px__layer--hills" data-rate="0.3" aria-hidden="true">
<!-- 下の文字層は data-rate="0.55" -->

実測で scrollTop 120 のとき3層が収束した位置は -14.4 / -36 / -66px で係数 0.12・0.3・0.55 のちょうど積、60 では正確に半分の -7.2 / -18 / -33px で比率が保たれました。

lerp で毎フレーム追う

function lerp(a, b, t) {
  return a + (b - a) * t;
}
function frame() {
  var target = view.scrollTop;
  // 毎フレーム差の12パーセントずつ近づくので速いスクロールでもつまずかず、0.1px 以内ならちょうど合わせる
  current = Math.abs(target - current) < 0.1 ? target : lerp(current, target, 0.12);
  for (var i = 0; i < layers.length; i++) {
    var rate = parseFloat(layers[i].getAttribute('data-rate')) || 0;
    layers[i].style.transform = 'translate3d(0, ' + (-current * rate).toFixed(2) + 'px, 0)';
  }
  requestAnimationFrame(frame);
}

translate3d は合成処理に任せられる変換なので毎フレーム回る用途に向いており、JS なしで同じ奥行きを作る軸はCSS パララックス スクロール 9種が別に扱います。

09スナップセクション

scroll-snap-type でセクションが画面にぴたりと収まり、観察者が中央に来たセクションを選んで aria-current で示します。

スナップセクション
新しいタブで開く

09 · スナップセクション — 計測 累積面積 35.251% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scroll-snap-typescroll-snap-alignaria-current

中央の検出は rootMargin の仕事

function pick(entries) {
  entries.forEach(function (entry) {
    if (!entry.isIntersecting) return;
    var i = panels.indexOf(entry.target);
    if (i < 0) return;
    panels.forEach(function (panel, j) {
      if (j === i) { panel.setAttribute('aria-current', 'true'); }
      else { panel.removeAttribute('aria-current'); }
    });
  });
}
// 画面中央の10パーセント帯に掛かったパネルが中央のパネル
var io = new IntersectionObserver(pick, { root: view, rootMargin: '-45% 0px -45% 0px', threshold: 0 });

上下から45パーセントずつ切り落とすと画面中央の10パーセント帯だけが残り、実測でスナップ点は 0 / 160 / 320 — scrollTop を 80 に置けば 0 に、240 なら 160 に吸着し、aria-current は毎回中央のパネルへ移り変わりました。

スナップの CSS は3行

.ss__view {
  scroll-snap-type: y mandatory;
}
.ss__panel {
  scroll-snap-align: center;
}

プレビューの間だけ scroll-snap-type を none にゆるめ、触れると戻す処理も入っています — 有効なままのスナップがアニメーション中のトラックを掴み直してプレビュー全体を打ち消すのを防ぐためです。

スクロール検知はリスナーと IntersectionObserver のどちらに置く?

答えを先に書くと、入った・出たの検知には観察者、連続値が必要なときにはリスナーです。多くの スクロール アニメーション css の例はちょうどこの分岐から始まります。scroll リスナーは毎フレーム呼ばれ、その中で getBoundingClientRect() を呼んだ瞬間にレイアウトを再計算します。IntersectionObserver はブラウザがレイアウト処理の合間に余裕を持って値を渡してくれるので、01〜06 のような「いつ点け・消すか」という問題には観察者が正解です。裏返すと、07・08 のように刻々と変わる位置や速度といった連続値には観察者では答えられません — コールバックはしきい値を通るときにしか来ないからです。そのときはリスナーの代わりに requestAnimationFrame のループで scrollTop を読み、読みは毎フレーム1回にまとめます。この記事で扱う スクロール トリガー 使い方 の分かれ道は、結局このひとつです。

どこで壊れるか — プレビューは動くのに手では動かないコード

グリッドの動きは CSS キーフレームで、人がスクロールする瞬間は観察者のコードです。2つは別の経路なので、片方が死んでいてもレンダリングの測定は通ります。

4枚のカードが一斉に点いた

01 の最初の実測では、プレビューではカードが順に出てくるように見えたのに、操作プローブでスクロールを 30 進めただけで4枚が 1111 と一斉に発火しました。ビューの高さ 156px がカード4枚を一度に収めてしまい、しきい値を同時に越えたのです。ビューを 100px に下げてカードを大きくして初めて、20・60・120 の段階で 1100 → 1110 → 1111 と順に掛かりました。プレビューの animation-delay の階段はこの順番をなぞったものにすぎず、実際に順番を作るのはビューポートとしきい値の幾何でした。

フェードがポスターに幽霊として残った

01 の初版はカードの出現を opacity フェードひとつに束ねていました。24フレームは2秒を4.2パーセント間隔で拾うのでフェードの中間がそのままフレームになり、ポスターには半分見えるカードが重なって写りました — 修正前の測定は面積 29.19パーセント・強度 114.4。出現を steps(1, end) のカットに、移動を transform のイージングに分けると同じ場面で強度は 171.4 まで上がり、幽霊は消えました。9つのアーカイブのパスワードは 82xy9zm9 で、02 の3行に折り返した状態ラベルや 04 の下端で見切れた一文も同じ回の目視検査で拾いました。

ポスターは1枚しか選ばれない

02 はゲージの数字が 1.00 なのに強調されたしきい値候補は 0.25 という場面がポスターになったこともあります — 別々に位相を回っていた2要素のループを合わせて解決しました。これはグリッドの目視確認を測定値が代行できない理由でもあり、スクロール インジケーター 9種でも同じ教訓が繰り返されました — 正確な基準はMDN の IntersectionObserverとscroll イベントです。

3つのバリエーションとアクセシビリティ

変数3つを変えるだけで9つのエンジンの性格がまとめて変わります。

名前 変える値 感じ
duration $dur-enter .6s → .3s セクションの出現が2倍速くなり、かすめるように過ぎる
easing $ease-spring → $ease-pop 出現が到着時にひと跳ねする
color $subject-blue → $stage-orange 強調色が冷たい青から暖かいオレンジへ変わる

アクセシビリティは2つに分かれます。出現系(01・03・05・09)は prefers-reduced-motion: reduce で transition とアニメーションを切り、要素を最初から見えた状態に置きます — 内容が消えてはならないからです。連続系(07・08)は速度の効果だけを切り、位置の計算はそのままです。カウンター(04)は reduce では数えずに目標値をすぐ示します。aria-current (09) は動きではなく状態の表示なので、どちらの側でも保たれます。

FAQ

この スクロール トリガー 使い方 のコードをそのままコピーして動きますか?

動きます。アーカイブの vanilla/ フォルダは index.html と style.css・style.scss の3ファイルだけなので、その index.html をブラウザで開くだけで動きます。観察者が付く対象は data-scroll 属性を持つ要素なので、HTML にクラスと属性を移すだけで別のページにも組み込めます。

GSAP の ScrollTrigger を使った方がよくないですか?

規模が大きくなると、スクラブ(スクロールの進行度をアニメーションの時間に対応づける)やタイムラインの整列といった機能が効いてきます。しかしこの記事の9つのような出現の検知・カウンター・スナップの水準なら、観察者を数十行書けば足り、ライブラリの読み込みと学習コストを省けます。現実的な道はこのエンジンで始めて、スクラブが必要になった瞬間にだけ拡張することです。

iPhone の Safari でも IntersectionObserver は動きますか?

動きます。観察者は2019年から Safari を含む主要ブラウザすべてに実装されており、scroll-snap-type (09)も同じです。気をつけるべきは対応ではなく性能です — scroll リスナーの中で DOM を読むとモバイルが真っ先に重くなるので、07・08 のように読みをまとめて requestAnimationFrame で回す書き方をそのまま使うのが安全です。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。