7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

水平方向の無限スクロールを綺麗に実装しよう

Posted at

movie01.gif

画像やテキストなどが永遠と流れ続ける水平スクロールは
おしゃれなサイトでよく見かけると思います。

そんな水平スクロールですが、意外と手間取ることもあったので
実装方法を考え直してみました。

結論

先に結果辿り着いたコードを載せます。
細かい要件がなければこのコードで実装できそうです。

スクロールはCSSのキーフレームアニメーションで、
要素の複製個数などをJSで指定できるように作成しました。

水平方向無限スクロール関数(JS)
/**
 * 水平方向無限スクロール
 *
 * @example infiniteScroller();
 * @example infiniteScroller(target, { clones: 3 });
 * @example infiniteScroller(scrollTarget, {
 *            clones: 1,
 *            gap: "30px",
 *            duration: "15s",
 *            direction: "left",
 *            pauseOnHover: true
 *          });
 */

export const infiniteScroller = (target, options) => {
  const defaultOptions = {
    clones: 1,
    direction: "left",
    duration: "20s",
    pauseOnHover: false
  };
  const scrollOptions = { ...defaultOptions, ...options };

  const body = document.body;
  const scrollTarget = target || document.querySelector(".js-scrollTrack");
  const scrollList = scrollTarget.querySelector(".js-scrollList");
  const scrollCont = scrollTarget.querySelectorAll(".js-scrollCont");
  const cloneLength = scrollOptions.clones + 1;

  const init = () => {
    scrollTarget.setAttribute("data-scroll-initialized", "true");
    body.style.setProperty(
      "--_infinite-scroll-clone-length",
      cloneLength.toString()
    );

    scrollOptions.direction === "left"
      ? scrollTarget.setAttribute("data-scroll-direction", "left")
      : scrollTarget.setAttribute("data-scroll-direction", "right");

    if (scrollOptions.pauseOnHover)
      scrollTarget.setAttribute("data-scroll-pause-on-hover", "true");

    if (scrollOptions.gap)
      body.style.setProperty("--_infinite-scroll-gap", scrollOptions.gap);
    if (scrollOptions.duration)
      body.style.setProperty(
        "--_infinite-scroll-duration",
        scrollOptions.duration
      );

    for (let i = 0; i < scrollOptions.clones; i++) {
      scrollCont.forEach((element) => {
        const duplicatedItem = element.cloneNode(true);
        duplicatedItem.setAttribute("aria-hidden", "true");
        scrollList
          ? scrollList.appendChild(duplicatedItem)
          : scrollTarget.appendChild(duplicatedItem);
      });
    }
  };

  init();
};
最低限必要なCSS(SCSS)

body {
  --scroll-gap: 30px;// 別で定義した余白
}

@keyframes infiniteScrollRTL {
  0% {
    transform: translateX(0);
  }
  100% {
    transform: translateX(
      calc(
        -1 * (100% / var(--_infinite-scroll-clone-length)) - var(
            --_infinite-scroll-gap,
            var(--scroll-gap)
          ) / var(--_infinite-scroll-clone-length)
      )
    );
  }
}

@keyframes infiniteScrollLTR {
  0% {
    transform: translateX(
      calc(
        -1 * (100% / var(--_infinite-scroll-clone-length)) - var(
            --_infinite-scroll-gap,
            var(--scroll-gap)
          ) / var(--_infinite-scroll-clone-length)
      )
    );
  }
  100% {
    transform: translateX(0);
  }
}

.js-scrollTrack {
  width: max-content;

  &[data-scroll-initialized="true"][data-scroll-direction="left"] {
    animation: infiniteScrollRTL var(--_infinite-scroll-duration) linear
      infinite;
  }
  &[data-scroll-initialized="true"][data-scroll-direction="right"] {
    animation: infiniteScrollLTR var(--_infinite-scroll-duration) linear
      infinite;
  }
  &[data-scroll-pause-on-hover="true"]:hover {
    animation-play-state: paused;
  }
}

.js-scrollList {
  display: flex;
  flex-wrap: nowrap;
  gap: 0 var(--_infinite-scroll-gap, var(--scroll-gap));// 他の場所で定義した余白を指定したいときはオプションで指定せずにフォールバックを使用する
}

.js-scrollCont {
  flex-shrink: 0;
}

// ラップする要素にoverflow: hidden;を指定する
.scroll_wrap {
  width: 100%;
  overflow: hidden;
}

実装方法

水平方向の無限スクロールを実装する方法を考えていきます。

ベースになるレイアウト

See the Pen Untitled by rhrh__8 (@Right-88) on CodePen.

ベースになる要素は大体こんな感じです。

  • 親要素.scroll_wrapoverflow: hiddenする
  • .scroll_trackをスクロールさせたい
  • 子要素のリストにdisplay: flexを指定する
  • 子要素の余白はgapを指定する

要点はこれくらいですね!


① いにしえのmarquee要素【※超非推奨】

See the Pen horizontal scroll marquee by rhrh__8 (@Right-88) on CodePen.


パターン1個目、
いにしえの<marquee>要素を使った方法です。

タグで囲うだけで要素を自動でスクロールさせることができます。
HTML5で廃止になっているので使わないようにしましょう。
おまけで入れました。

<div class="scroll_wrap">
  <marquee behavior="scroll" direction="left" class="scroll_track">
    <ul class="scroll_inner">
    <!-- 略 -->
    </ul>
  </marquee>
</div>

余談ですが意外と属性で指定できるオプションが豊富でびっくりしました。


② CSSアニメーションで実装

See the Pen horizontal scroll CSS by rhrh__8 (@Right-88) on CodePen.


パターン2個目、
CSSのキーフレームアニメーションを使用する方法です。


/** 省略してます **/

body {
  --scroll-gap: 30px;// .scroll_inner子要素のgap
}

@keyframes infiniteScroll {
  0% {
    transform: translateX(0);
  }
  100% {
    transform: translateX(-100%);
  }
}

.scroll_track {
  animation: infiniteScroll 10s linear infinite;
}

animation-iteration-countinfiniteに指定してループさせます。

要素をコピペ

これだけだとループ時に余白ができてしまうので、
.scroll_cont要素を同じ数だけ追加しています。

<div class="scroll_wrap">
  <div class="scroll_track">
    <ul class="scroll_inner">
      <li class="scroll_cont">
        <p>コンテンツ①</p>
      </li>
      <li class="scroll_cont">
        <p>コンテンツ②</p>
      </li>
      <li class="scroll_cont">
        <p>コンテンツ③</p>
      </li>
      <li class="scroll_cont">
        <p>コンテンツ④</p>
      </li>
      <li class="scroll_cont">
        <p>コンテンツ⑤</p>
      </li>
      <li class="scroll_cont">
        <p>コンテンツ①</p>
      </li>
      <li class="scroll_cont">
        <p>コンテンツ②</p>
      </li>
      <li class="scroll_cont">
        <p>コンテンツ③</p>
      </li>
      <li class="scroll_cont">
        <p>コンテンツ④</p>
      </li>
      <li class="scroll_cont">
        <p>コンテンツ⑤</p>
      </li>
    </ul>
  </div>
</div>

ホバー時にアニメーションを止める

&:hover {
  animation-play-state: paused;
}

animation-play-stateを指定して、
ホバー時にアニメーションを止めることができます。

アニメーションがループする時のジャンプ問題

movie02.gif

先ほどのtranslateXを0から-100%にうごかすやり方だと
アニメーションのループ時に、このGIFのようにジャンプが発生してしまいます。

これを解決💡するために下記のように変更を加えました。

body {
  --scroll-gap: 30px;
}

- @keyframes infiniteScroll {
-  0% {
-    transform: translateX(0);
-  }
-  100% {
-    transform: translateX(-100%);
-  }
- }
+ @keyframes infiniteScroll {
+  0% {
+    transform: translateX(0);
+  }
+  100% {
+    transform: translateX(calc(-50% - var(--scroll-gap) / 2));
+  }
+ }

.scroll_track {
+ width: max-content;
  animation: infiniteScroll 10s linear infinite;
}
  • width: max-content;を指定してスクロールさせる要素の幅を子要素に合わせます
  • コンテンツを2つに複製した分、全体の横幅のちょうど1/2の位置になるようにtranslateXの値を指定します

これでCSSアニメーションを使って綺麗な無限スクロールが実装できました✨

CSSアニメーションで実装した時のデメリット

CSSアニメーションだけでも簡単に実装することができますが……

スクロールコンテンツの幅が足りない時は
要素をコピペで複製しないといけず、
アニメーションの数値もそれに合わせて調整 しなければいけません。


③ スライダーライブラリを使用する

See the Pen horizontal scroll Splide by rhrh__8 (@Right-88) on CodePen.


スライダーのJSライブラリを使用する方法です。

今回はアクセシビリティにも配慮している Splide
Auto Scroll 拡張機能を使用します。

const splideTarget = document.querySelector('.splide');
const splideOptions = {
  type: 'loop',
  autoWidth: true,
  arrows: false,
  pagination: false,
  autoScroll: {
    speed: 1,
    pauseOnHover: false, // スライダーの上にマウスカーソルが乗ったとき、スクロールを停止するかどうか
    pauseOnFocus: false, // スライダー内にフォーカスされた要素がある場合、スクロールを停止するかどうか
  },
};

if (splideTarget) {
  const splide = new Splide(splideTarget, splideOptions);
  splide.mount({ AutoScroll });
}

いいところ

type: 'loop'を指定すれば自動でクローンスライドを作成してくれます。

また、フォーカス時の動作を制御したり、
フリードラッグモードと併用することができるので
細かい要件がある場合はこの方法がいいのではないでしょうか。

スライダーライブラリで実装した時のデメリット

細かく設定もできて完璧!!な感じもしますが
スライダーライブラリを使った方法にもデメリットがあります…

デバイス間で速度が安定しない

自動スクロールのスピードをピクセル/フレーム単位で指定しているため
フレームレートの違うデバイス間でスクロールのスピードが変わってしまうことがあります。

Splideでは…

同じspeedを指定してもかなりスピードが変わって見えます
↓上: Chrome 下: Safari (検証環境 Mac:12.3.1, Chrome:123.0, Safari:15.4)
movie03.gif

requestAnimationFrameを使用して自動スクロールを実装している以上
デバイスごとのフレームレートの影響を受けることになります。

Auto Scroll の issueでこの問題について取り組んでいる猛者たちがいましたが、
まだクローズはしていないみたいです(※2024年4月7日現在)

容量の増加

また、単純にライブラリを入れた分容量が増えます。
他にもスライダーを実装しているサイトならいいのですが
装飾目的の自動スクロールのためだけに追加するのも少し気が引けます。。


④ CSSアニメーション + JSでクローン制御

そこで
ここまでの方法を組み合わせて、
アニメーション部分は②のようにCSSのキーフレームアニメーションで、
クローンなどの制御をJSでできるように実装してみました。

See the Pen horizontal scroll CSS + JS by rhrh__8 (@Right-88) on CodePen.


コードは冒頭の結論に貼り付けてます。
※CodePenでは関数をexportせずに直接使ってます。

ちょこっと説明

詳しくはCodePenを見てみて下さい。

cloneNode()を使って指定した回数要素を複製

clones オプションで指定した回数分、
cloneNode()でノードを複製します。

以下の点に注意!

  • イベントリスナーは複製されない
  • ID、name属性などは重複してしまうため、つけたい場合は間数の実行後に動的につける

cloneの回数をCSS変数にセットし、移動距離を計算

clones オプションに設定したcloneの回数を
CSS変数 --_infinite-scroll-clone-length にセットし、
アニメーションの距離を変化させています。

// 左方向
@keyframes infiniteScrollRTL {
  0% {
    transform: translateX(0);
  }
  100% {
    transform: translateX(
      calc(
        -1 * (100% / var(--_infinite-scroll-clone-length)) - var(
            --_infinite-scroll-gap,
            var(--scroll-gap)
          ) / var(--_infinite-scroll-clone-length)
      )
    );
  }
}

これで、足りない分の複製も簡単になりました✨

(clones に『999』のような極端に大きい数字を入れても一応動作しました。
かなり重くなるのでやめた方がいいです)

アクセシビリティを考慮

クローンした要素にはaria-hidden="true"が付くようにしています。

後書き

正直、スライダーが必要なければ
で書いたHTML,CSSだけで実装するやり方で事足りる気もするのですが
色々拡張もできそうなので機会があれば使っていこうと思います。

7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?