GSAPで無限スクロールをスクロール量に応じて速くしてみる(備忘録)

今回は、ずっと個人的に気になっていた無限スクロール部分がスクロール量に応じて速くなるというのを実装してみます。

実は過去に何度か挑戦してうまくいかなかったのですが、今回はなんとか実装することができました。

GSAPを使って無限スクロールをスクロール量に応じて速くなるように調整しています。

それではやっていきましょう。

HTMLの記述について

<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>GSAPで無限スクロールをスクロール量に応じて速くしてみる</title>
  <link rel="stylesheet" href="../reset.css">
  <link rel="stylesheet" href="./style.css">
</head>

<body>
  <main>
    <section></section>
    <section></section>
    <div class="bl_loop_inner">
      <div class="bl_loop_target">
        <img src="./images/img_01.webp" alt="" />
        <img src="./images/img_02.webp" alt="" />
        <img src="./images/img_01.webp" alt="" />
        <img src="./images/img_02.webp" alt="" />
        <img src="./images/img_01.webp" alt="" />
        <img src="./images/img_02.webp" alt="" />
        <img src="./images/img_01.webp" alt="" />
        <img src="./images/img_02.webp" alt="" />
        <img src="./images/img_01.webp" alt="" />
        <img src="./images/img_02.webp" alt="" />
        <img src="./images/img_01.webp" alt="" />
        <img src="./images/img_02.webp" alt="" />
      </div>
    </div>
    <section></section>
    <section></section>
  </main>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
  <script src="./style.js"></script>
</body>

</html>

section部分は該当の無限スクロール部分まで距離をとりたいので、入れています。

該当の無限スクロール部分はdivタグのbl_loop_targetのclassで囲っている部分です。

またGSAPを使うため、下記の記述を追記しています。今回は、ScrollTriggerを使います。

  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>

今回はCDNを使っていますが、npmやyarnを使って対応することもできます。下記公式ドキュメントに記載してあります。

Installation | GSAP | Docs &amp; Learning

上記ページからCDNを選択し、ScrollTriggerにチェックを入れ、赤枠に表示されたscriptのコードをHTMLへ貼り付ければOKです。

CSSの記述について

@charset "utf-8";

/* ==========================
  初期設定
========================== */
*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  position: relative;
  word-wrap: break-word;
}

img {
  width: 100%;
}

/* レイアウト設定 */
section {
  height: 100vh;
}

section:nth-child(1) {
  background-color: #ccc;
}

section:nth-child(2) {
  background-color: #444;
}

section:nth-child(3) {
  background-color: #ccc;
}

section:nth-child(4) {
  background-color: #444;
}

section {
  background-color: #ccc;
}

img {
  width: 100%;
  height: auto;
  vertical-align: bottom;
  margin: 0;
}

.bl_loop_inner {
  overflow: hidden;
  display: flex;
}

.bl_loop_target {
  display: flex;
  gap: 24px;
}

.bl_loop_target img {
  width: 400px;
}

.bl_loop_target + .bl_loop_target {
  margin-left: 24px;
}

bl_loop_innerの箇所で、overflowをhiddenにしてはみ出した部分が表示されないように設定しています。

あとはbl_loop_targetの箇所で、displayをflexにして画像を並べているだけです。

bl_loop_targetにmargin-leftを設定しているのは、JavaScript側で要素を複製するためです。

JavaScriptの記述について

"use strict";

// GSAPライブラリを初期化し、ScrollTriggerプラグインを使用可能にする
gsap.registerPlugin(ScrollTrigger);

// 無限スクロールの対象となる要素を取得し、その複製を作成してDOMに追加する
const loopTarget = document.querySelector(".bl_loop_target");
const clone = loopTarget.cloneNode(true);
loopTarget.parentNode.appendChild(clone);

// 要素間のギャップとマージンを定義し、複製された要素の全体幅を計算する
const gap = 24; // .bl_loop_target img要素間のギャップ
const marginLeft = 24; // 複製された要素間の左マージン
const targetWidth = loopTarget.offsetWidth + gap + marginLeft; // 全体の幅を計算

// 要素とその複製に幅を設定し、無限スクロールアニメーションを定義する
gsap.set([loopTarget, clone], {
  width: targetWidth,
});
const loopAnimation = gsap
  .timeline({
    repeat: -1, // アニメーションを無限に繰り返す
    defaults: {
      ease: "none",
    }, // イージングを無しに設定
  })
  .to([loopTarget, clone], {
    x: -targetWidth, // 要素を左に移動し、複製された要素が表示されるようにする
    duration: 30, // 移動にかかる時間(無限スクロールの基本速度)
    ease: "none",
  })
  .to([loopTarget, clone], {
    x: 0, // 位置をリセット
    duration: 0,
  });

// スクロールイベントを検出し、スクロール速度に基づいてアニメーションの速度を調整する
let lastScrollTop = 0;
let lastTimestamp = 0;
let scrollTimeout;
window.addEventListener("scroll", () => {
  const currentScrollTop =
    window.pageYOffset || document.documentElement.scrollTop;
  const currentTimestamp = Date.now();

  // 前回のスクロールイベントからの時間と距離を計算し、スクロール速度を求める
  if (lastTimestamp !== 0) {
    const deltaTime = currentTimestamp - lastTimestamp;
    const deltaScroll = currentScrollTop - lastScrollTop;
    const scrollSpeed = Math.abs(deltaScroll) / deltaTime;
    // スクロール速度に応じてアニメーションの速度(timeScale)を調整
    loopAnimation.timeScale(Math.max(1, scrollSpeed * 10));
  }

  lastScrollTop = currentScrollTop;
  lastTimestamp = currentTimestamp;

  // スクロールが終了したとみなされる場合、アニメーションの速度をリセット
  clearTimeout(scrollTimeout);
  scrollTimeout = setTimeout(() => {
    loopAnimation.timeScale(1); // アニメーションの速度を元に戻す
  }, 200); // スクロールイベントが200ms以上発生しなければスクロール終了とみなす
});

下記公式ドキュメントに基づいて、対応していきます。

Installation | GSAP | Docs &amp; Learning

まずは下記画像の赤枠のgsap.registerPlugin(ScrollTrigger)を追記します。

// GSAPライブラリを初期化し、ScrollTriggerプラグインを使用可能にする
gsap.registerPlugin(ScrollTrigger);

無限スクロールの対象となる要素を取得し、その複製を作成してDOMに追加します。

// 無限スクロールの対象となる要素を取得し、その複製を作成してDOMに追加する
const loopTarget = document.querySelector(".bl_loop_target");
const clone = loopTarget.cloneNode(true);
loopTarget.parentNode.appendChild(clone);

これで下記HTMLの箇所が1つから2つになります。

<div class="bl_loop_target">
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
</div>
<div class="bl_loop_target">
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
  <img src="./images/img_01.webp" alt="" />
  <img src="./images/img_02.webp" alt="" />
</div>

要素間のギャップとマージンを定義し、複製された要素の全体幅を計算します。これで無限スクロール時のずれを無くします。

// 要素間のギャップとマージンを定義し、複製された要素の全体幅を計算する
const gap = 24; // .bl_loop_target img要素間のギャップ
const marginLeft = 24; // 複製された要素間の左マージン
const targetWidth = loopTarget.offsetWidth + gap + marginLeft; // 全体の幅を計算

要素とその複製に幅を設定し、無限スクロールアニメーションを定義します。

repeatを−1にしてアニメーション無限に繰り返すようにして、easeは一定にしたいのでnoneにします。

今回は無限スクロールの移動方向は左側にします。xを-targetWidthにして設定します。

durationは30に設定していますが、ここは任意の値に変えていただいて大丈夫です。

最後に位置をリセットするため、xやdurationの値を0にしています。

// 要素とその複製に幅を設定
gsap.set([loopTarget, clone], {
  width: targetWidth,
});

// 無限スクロールアニメーションを定義する
const loopAnimation = gsap
  .timeline({
    repeat: -1, // アニメーションを無限に繰り返す
    defaults: {
      ease: "none",
    }, // イージングを無しに設定
  })
  .to([loopTarget, clone], {
    x: -targetWidth, // 要素を左に移動し、複製された要素が表示されるようにする
    duration: 30, // 移動にかかる時間(無限スクロールの基本速度)
    ease: "none",
  })
  .to([loopTarget, clone], {
    x: 0, // 位置をリセット
    duration: 0,
  });

スクロールイベントを検出し、スクロール速度に基づいてアニメーションの速度を調整します。

あとはスクロールしたときに、無限スクロールの速度を調整していきます。

addEventListenerのscrollイベントの中にコードを書いていきます。

スクロール位置、現在までの経過時間を取得し、前回のスクロールイベントからの時間と距離を計算し、スクロール速度を求めます。

その後、lastScrollTopとlastTimestampの情報を現在の情報に書き換え、スクロールが終了したとみなされる場合、アニメーションの速度をリセットしています。

// スクロールイベントを検出し、スクロール速度に基づいてアニメーションの速度を調整する
let lastScrollTop = 0;
let lastTimestamp = 0;
let scrollTimeout;
window.addEventListener("scroll", () => {
  const currentScrollTop =
    window.pageYOffset || document.documentElement.scrollTop;
  const currentTimestamp = Date.now();

  // 前回のスクロールイベントからの時間と距離を計算し、スクロール速度を求める
  if (lastTimestamp !== 0) {
    const deltaTime = currentTimestamp - lastTimestamp;
    const deltaScroll = currentScrollTop - lastScrollTop;
    const scrollSpeed = Math.abs(deltaScroll) / deltaTime;
    // スクロール速度に応じてアニメーションの速度(timeScale)を調整
    loopAnimation.timeScale(Math.max(1, scrollSpeed * 10));
  }

  lastScrollTop = currentScrollTop;
  lastTimestamp = currentTimestamp;

  // スクロールが終了したとみなされる場合、アニメーションの速度をリセット
  clearTimeout(scrollTimeout);
  scrollTimeout = setTimeout(() => {
    loopAnimation.timeScale(1); // アニメーションの速度を元に戻す
  }, 200); // スクロールイベントが200ms以上発生しなければスクロール終了とみなす
});

これにて実装完了です。

正攻法がどのようなものかはわかりませんが、とりあえず動いてよかったです。最初はCSSのkeyframesを使うなど、いろいろ試してみたのですが実現ができず諦めかけていました。

デザインギャラリーを見ていると、よく見かけると思いましたので、紹介してみました。