画面縦スクロールでコンテンツを横スクロール : GSAPなしでハイブリッドスクロールを実装

画面縦スクロールでコンテンツを横スクロール : GSAPなしでハイブリッドスクロールを実装

画面を縦スクロールしていくと、途中からコンテンツが横にスクロールするWebページ、たまに見かけると思います。代表的な製品やその特徴を並列的に提示したり、手順などを順番に示したいケースで、印象的に表現することができます。

その実装にはJavaScriptライブラリのGSAPを利用する方法もありますが、多機能なだけに融通が効かず、思ったようにコントロールするのが難しい印象です。

今回は、素のJavaScriptとCSSでシンプルに実装してみます。

サンプルコード

HTML

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Hybrid Scroll Sample</title>
  </head>
  <body>
    <h1>Hybrid Scroll Sample</h1>
    <article>
      <h2>article 1</h2>
    </article>
    <div class="horizontal_scroll">
      <article class="sticky">
        <h2>article 2</h2>
        <div class="scroller">
          <section>
            <h3>section 1</h3>
          </section>
          <section>
            <h3>section 2</h3>
          </section>
          <section>
            <h3>section 3</h3>
          </section>
        </div>
      </article>
    </div>
    <article>
      <h2>article 3</h2>
    </article>
    <div class="horizontal_scroll">
      <article class="sticky">
        <h2>article 4</h2>
        <div class="scroller">
          <section>
            <h3>section 1</h3>
          </section>
          <section>
            <h3>section 2</h3>
          </section>
          <section>
            <h3>section 3</h3>
          </section>
          <section>
            <h3>section 4</h3>
          </section>
          <section>
            <h3>section 5</h3>
          </section>
          <section>
            <h3>section 6</h3>
          </section>
        </div>
      </article>
    </div>
  </body>
</html>

CSS

@charset 'UTF-8';

body {
  margin: 0;
  overflow-x: clip;
}
article:not(.sticky) {
  display: flex;
  flex-direction: column;
  justify-content: center;
  height: 100vh;
  background-color: whitesmoke;
}
article h2 {
  text-align: center;
}
.horizontal_scroll {
  --sticky-container-height: 100vh;
  height: var(--sticky-container-height);
  min-height: 100vh;
  box-sizing: border-box;
}
.horizontal_scroll .sticky {
  position: sticky;
  top: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: 100vh;
}
.horizontal_scroll .scroller {
  display: flex;
  overflow: auto;
}
.horizontal_scroll .scroller.nobar {
  overflow: hidden;
}
.horizontal_scroll .scroller > * {
  flex-basis: 66%;
  flex-shrink: 0;
  aspect-ratio: 16 / 9;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: whitesmoke;
}
.horizontal_scroll .scroller > *:not(:first-child) {
  margin-left: 1%;
}

JavaScript

"use strict";

document.addEventListener("DOMContentLoaded", () => {
  /* horizontal scroll */
  const stickyContainers = document.querySelectorAll(".horizontal_scroll");

  stickyContainers.forEach((stickyContainer) => {
    // get elements
    const stickyItem = stickyContainer.querySelector(".sticky");
    const scroller = stickyContainer.querySelector(".scroller");
    scroller.classList.add("nobar");

    // set sticky height
    const updateStickyHeight = () => {
      const stickyHeight = scroller.scrollWidth - scroller.clientWidth + stickyItem.clientHeight;
      stickyContainer.style.setProperty("--sticky-container-height", `${stickyHeight}px`);
    };
    updateStickyHeight();
    new ResizeObserver(updateStickyHeight).observe(scroller);
    new ResizeObserver(updateStickyHeight).observe(stickyItem);

    // sync scroll
    const syncScroll = () => {
      const rect = stickyContainer.getBoundingClientRect();
      if (rect.top <= 0 && rect.bottom >= window.innerHeight) {
        scroller.scrollLeft = window.scrollY - stickyContainer.offsetTop;
      } else if (rect.bottom < window.innerHeight) {
        scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth;
      } else {
        scroller.scrollLeft = 0;
      }
    };
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            window.addEventListener("scroll", syncScroll, { passive: true });
            syncScroll();
          } else {
            window.removeEventListener("scroll", syncScroll);
          }
        });
      },
      { threshold: 0 }
    );
    observer.observe(stickyContainer);

    // end of forEach
  });

  // end of DOMContentLoaded
});

サンプルDEMO

実際のサンプルページは[DEMOページ]ボタン押下で別タブ表示します。

コード解説

基本方針は次のとおりです。

  • 画面の縦スクロール量分、コンテンツを横スクロールさせる
  • 横スクロールしている間、縦スクロールは sticky で固定する
  • sticky で固定するためにコンテナを設けて、横スクロール分を含めた高さを設定する

HTML

ここでは、body 要素に article 要素が複数並んでいます。

横スクロールさせたいコンテンツを含む article 要素に sticky クラスを付与します。これを sticky アイテムにしますので、sticky コンテナとして horizontal_scroll クラスを付与した要素でラップしています。
また、横スクロールさせたいコンテンツ群(ここでは section 要素)のコンテナ要素に scroller クラスを付与します。

以下で解説する CSS と JavaScript は、外部ファイルにして読み込むなり、インラインで記述するなりしてください。
複数設置したかったのでコードが長くなってしまいましたが、構造はとても単純です。

CSS

23-30行目の sticky クラス要素への指定において、sticky 配置でコンテナ最上部に固定、最小の高さを 100vh に指定しています。
そのコンテナ要素である horizontal_scroll クラス要素は、17-22行目で指定しています。コンテンツの横スクロール分を含めた高さを設定したいのですが、予見できないので CSS 変数 "--sticky-container-height" を設定しています。のちほど JavaScript で動的に設定しますが、一応、初期値として 100vh が指定してあります。

横スクロールのコンテナとなる scroller クラス要素は、31-34行目で指定しています。flex でアイテムを横に並べて、overflow を auto にして横スクロールバーが表示されるようにしています(flex アイテムは40行目で、縮まずに溢れるようにしてあります)。
続けて35-37行目で、同要素に JavaScript で nobar クラスが付加されたときに overflow が hidden に変わって隠れるようにしています。これは、JavaScript が無効の状態でも、横スクロールでコンテンツを表示できるようにする配慮です。ただし、ファーストビューに含まれる場合は、表示がチラつく可能性があるので、初めから hidden にしておいた方がよいかもしれません。

最後に3-6行目の body への指定で、overflow-x を clip にしています。この指定は、今回のサンプルでは必要ないのですが、overflow を hidden にしている場合はその子孫要素で sticky 配置が効かないため、その代替手段を示すために記述しています。
例えば、"margin: 0 calc(50% - 50vw);" との併用で "body { overflow-x: hidden; }" とする手法はよくみられますが、clip に置き換えてください。同じように横スクロールを防ぐことができ、かつ、sticky が使えます。

以上が要点で、ほかの記述は体裁を整えているだけです。

JavaScript

14-20行目で、先の CSS 変数 "--sticky-container-height" を設定しています。sticky コンテナの高さになるので、sticky アイテムの高さ(clientHeight)に、コンテンツの横スクロール幅を足した値を与えています。
scrollWidth は overflow 分も含めたコンテンツ幅を取得するため、そこから clientWidth を引いて溢れ分のみの横スクロール幅を計算しています。
この関数 "updateStickyHeight" を、sticky アイテム(.sticky)や横スクロールコンテナ(.scroller)がリサイズした時と、初期時に実行しています。リサイズオブザーバーを使用することで、再計算が必要な時のみ効率よく実行されます。

23-32行目が、画面の縦スクロール量分、コンテンツを横スクロールさせる関数です。
sticky コンテナ(.horizontal_scroll)の上下が画面を覆っている時、画面の縦スクロール位置とsticky コンテナ上辺との差分を、横スクロールコンテナ(.scroller)のスクロール位置に指定しています。
なお、sticky コンテナが画面より上に通り過ぎた時には最大限スクロールした状態に、下に通り過ぎた時にはスクロールしていない状態に指定されます。この処理により、sticky コンテナが画面より上に通り過ぎた状態でページをリロードした場合でも、意図どおり横スクロールされた状態になります。

この関数 "syncScroll" を、33-46行目の交差オブザーバーで実行しています。sticky コンテナ(.horizontal_scroll)がビューポートと交差している時のみ、scroll イベントの発生を待機します。
画面をスクロールする度に処理が発生するため、必要な時だけ実行するようにして負荷の低減を図っています。

少し冗長な部分もありますが、やっていることはシンプルではないでしょうか。

おわりに

通常の縦スクロールと横スクロールを織り交ぜたこうした効果を、ハイブリッドスクロールと呼んだりするようです。印象的なユーザー体験を、比較的シンプルな実装で実現できました。

ですが、デザイン設計には注意も必要です。例えば、横スクロールするコンテンツがあまりに多いと、ユーザーはそれ以降のコンテンツになかなかたどりつけません。
また、ブラウザでの縦スクロールが効いていない(ように見える)ことは、ブラウザUIへの信頼性を毀損することにもなりかねません。今回のようなケースはセーフと考えますが、基本となるブラウザのユーザーインターフェースに関与するような効果の導入は、慎重な姿勢で検討すべきと心得ておきたいものです。

ソースコードの改善点などありましたら、ご指摘いただければうれしいです。Twitter(現 𝕏)は @uncl010 まで(あまり公私分けてないですが)。
それではまた、Happy coding !