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


![movie01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612989/9aa557d3-919c-197e-b1fc-3de0381dc130.gif)

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

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


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

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

<details><summary><b>水平方向無限スクロール関数（JS）</b></summary>


```javascript
/**
 * 水平方向無限スクロール
 *
 * @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();
};
```

</details>



<details><summary><b>最低限必要なCSS（SCSS）</b></summary>

```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;
}
```
</details>


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


## ベースになるレイアウト
<p class="codepen" data-height="300" data-default-tab="html,result" data-slug-hash="rNbderO" data-user="Right-88" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/Right-88/pen/rNbderO">
  Untitled</a> by rhrh__8 (<a href="https://codepen.io/Right-88">@Right-88</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

ベースになる要素は大体こんな感じです。
- 親要素`.scroll_wrap`を`overflow: hidden`する
- `.scroll_track`をスクロールさせたい
- 子要素のリストに`display: flex`を指定する
- 子要素の余白は`gap`を指定する

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

<br>

## ① いにしえのmarquee要素<font color="Crimson">【※超非推奨】</font>
<p class="codepen" data-height="300" data-default-tab="html,result" data-slug-hash="vYMRKev" data-user="Right-88" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/Right-88/pen/vYMRKev">
  horizontal scroll marquee</a> by rhrh__8 (<a href="https://codepen.io/Right-88">@Right-88</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

<br>

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

タグで囲うだけで要素を自動でスクロールさせることができます。
<font color="Crimson">HTML５で廃止になっているので使わないようにしましょう。<br>おまけで入れました。</font>

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

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

https://developer.mozilla.org/ja/docs/Web/HTML/Element/marquee

<br>

## ② CSSアニメーションで実装
<p class="codepen" data-height="300" data-default-tab="html,result" data-slug-hash="WNWzxPG" data-user="Right-88" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/Right-88/pen/WNWzxPG">
  horizontal scroll CSS</a> by rhrh__8 (<a href="https://codepen.io/Right-88">@Right-88</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

<br>

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

```scss

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

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-count`を`infinite`に指定してループさせます。

### 要素をコピペ

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

```html
<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>
```


### ホバー時にアニメーションを止める
```scss
&:hover {
  animation-play-state: paused;
}
```
`animation-play-state`を指定して、
ホバー時にアニメーションを止めることができます。

https://developer.mozilla.org/ja/docs/Web/CSS/animation-play-state


### アニメーションがループする時のジャンプ問題
![movie02.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612989/fc919091-d39a-9622-c37a-8989978be7f1.gif)

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

これを解決💡するために下記のように変更を加えました。
```diff_scss
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つに複製した分、全体の横幅の<font color="Chartreuse">ちょうど1/2の位置</font>になるようにtranslateXの値を指定します


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


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

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

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

<br>

## ③ スライダーライブラリを使用する
<p class="codepen" data-height="300" data-default-tab="js,result" data-slug-hash="VwNXKEe" data-user="Right-88" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/Right-88/pen/VwNXKEe">
  horizontal scroll Splide</a> by rhrh__8 (<a href="https://codepen.io/Right-88">@Right-88</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

<br>

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

今回はアクセシビリティにも配慮している [Splide](https://ja.splidejs.com/guides/getting-started/) の
[Auto Scroll](https://ja.splidejs.com/extensions/auto-scroll/) 拡張機能を使用します。

https://ja.splidejs.com/extensions/auto-scroll/


```javascript
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'`を指定すれば自動でクローンスライドを作成してくれます。

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

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

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

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

Splideでは…

同じ`speed`を指定してもかなりスピードが変わって見えます
↓上: Chrome  下: Safari  (検証環境 Mac:12.3.1, Chrome:123.0, Safari:15.4)
![movie03.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2612989/cb967e96-6e5e-ee99-bd33-128ad412fdf1.gif)

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

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

https://github.com/Splidejs/splide-extension-auto-scroll/issues/10



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


<br>

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

そこで
ここまでの方法を組み合わせて、
アニメーション部分は②のようにCSSのキーフレームアニメーションで、
クローンなどの制御をJSでできるように実装してみました。
<p class="codepen" data-height="300" data-default-tab="js,result" data-slug-hash="xxeWqLB" data-user="Right-88" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/Right-88/pen/xxeWqLB">
  horizontal scroll CSS + JS</a> by rhrh__8 (<a href="https://codepen.io/Right-88">@Right-88</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

<br>

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


### ちょこっと説明

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

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

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

:::note warn
以下の点に注意！
- イベントリスナーは複製されない
- ID、name属性などは重複してしまうため、つけたい場合は間数の実行後に動的につける
:::


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

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

```scss
// 左方向
@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"`が付くようにしています。

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