モダンなJSとCSSで作るライブラリ不要の全画面スクロール演出(2019年版)

118
134
306

スクロールで全画面がスライドのように切り替わるウェブサイトの表現があります。手軽にこの表現を実装するJSライブラリ、fullPage.jsを使ったことのある方もいるのではないでしょうか? かつては無料で使えたこのライブラリですが、現在はGPLライセンスのプロジェクト以外では使用料がかかります。

その一方、CSSとJavaScriptの進化により、このような表現をライブラリを使わずとも比較的簡単に実装できるようになりました。本記事では、基本的な機能をおさえた、全画面スクロールの実装方法を紹介します。

この記事を通じて以下の技術も学べます。

  • スクロールをピタッと止めるCSSプロパティscroll-snap-type
  • 画面と要素の交差を検知するIntersection Observer API
  • スムーススクロールが実装できるJavaScriptメソッドscrollIntoView()

スクロールをピタッと止めるCSS

特定の位置でピタッとスクロールを止めるscroll-snap-type というプロパティがあります。スクロールのスナップ、すなわち一定の範囲内であればスクロールを特定の位置にスナップさせるプロパティです。

scroll-snap-typeプロパティは1つ目の値にスナップさせるスクロール方向、2つ目の値に厳密さを指定します。

scroll-snap-type: y mandatory;

上記のようにmandatoryを指定することで、y方向のスクロールを厳密にスナップさせます。2つ目の値をproximityにするとスナップの強制力は緩くなり、スナップ点以外でも止めることもできます。

なお、このプロパティは要素内スクロール、つまり親要素にoverflow: scrolloverflow: autoを指定していないと有効になりませんのでご注意ください。

実はIE11も対応しているscroll-snap-type

意外にも(?)scroll-snap-typeはIE11でも使うことができます。ただし、次のブラウザは古い仕様での指定が必要になるのでご注意ください。

  • IE 11
  • Edge 18以前(非ChromiumのEdge)
  • Firefox 67以前
  • Safari 10以前

これらのブラウザでは以下のような書き方になります。

scroll-snap-type: mandatory;
scroll-snap-points-y: repeat(100px);

違いとしては、scroll-snap-typeに方向の指定がなく、その代わりscroll-snap-points-yというプロパティでスナップ点を指定します。現行仕様ですと、スナップ点は子要素に応じて作られますが、古い仕様では明示的にスナップ点を指定する必要がありました。また、repeatを使っている通り、等間隔でしか指定できません。(現行仕様では要素の大きさに応じて可変にできます)

古いバージョンのサポートも視野に入れた場合、以下のように書くとIE11などの古いブラウザにも対応できます。

scroll-snap-type: mandatory;
scroll-snap-points-y: repeat(100px);
-ms-scroll-snap-type: mandatory;
-ms-scroll-snap-points-y: repeat(100px);
-webkit-scroll-snap-type: mandatory;
-webkit-scroll-snap-points-y: repeat(100vh);
scroll-snap-type: y mandatory;

古いブラウザでは初めの2行のが読み込まれ、後半の行の新しい書き方は無視されます。新しいブラウザでは、最後の行のプロパティが最終的に読み込まれるので、いずれにせよscroll-snap-typeを実装できます。(IE11や古いSafariは別途-ms--webkit-のベンダープレフィックスが必要になります。このあたりはAutoprefixerなどを導入すると楽でしょう。)

フルページスクロールの実装

では、ここから実際の実装方法を解説します。

▼HTML

<div class="fullPageScroll">
  <section id="section1" class="section section1">
    <!-- 中略 -->
  </section>
  <section id="section2" class="section section2">
    <!-- 中略 -->
  </section>
  <section id="section3" class="section section3">
    <!-- 中略 -->
  </section>
  <section id="section4" class="section section4">
    <!-- 中略 -->
  </section>
  <section id="section5" class="section section5">
    <!-- 中略 -->
  </section>
</div>
<nav id="pagination" class="pagination">
  <a id="pagination1" href="#section1"></a>
  <a id="pagination2" href="#section2"></a>
  <a id="pagination3" href="#section3"></a>
  <a id="pagination4" href="#section4"></a>
  <a id="pagination5" href="#section5"></a>
</nav>

▼CSS

.fullPageScroll {
  width: 100%;
  height: 100vh;
  scroll-snap-type: y mandatory;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

.section {
  width: 100%;
  height: 100vh;
  padding: 10%;
  scroll-snap-align: start;
}

.pagination a.active {
  transform: scale(1.8);
}

CSSに関しては必要な部分だけを抜き出しています。

▼JavaScript

// スムーススクロール
const paginations = document.querySelectorAll(".pagination a");
paginations.forEach(pagination => {
  pagination.addEventListener("click", e => {
    e.preventDefault();
    const targetId = e.target.hash;
    const target = document.querySelector(targetId);
    target.scrollIntoView({ behavior: "smooth" });
  });
});

// Intersection Observer
const sections = document.querySelectorAll(".section");
const observerRoot = document.querySelector(".fullPageScroll");
const options = {
  root: observerRoot,
  rootMargin: "-50% 0px",
  threshold: 0
};
const observer = new IntersectionObserver(doWhenIntersect, options);
sections.forEach(section => {
  observer.observe(section);
});

/**
 * 交差したときに呼び出す関数
 * @param entries - IntersectionObserverEntry IntersectionObserverが交差したときに渡されるオブジェクトです。
 */
function doWhenIntersect(entries) {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      activatePagination(entry.target);
    }
  });
}

/**
 * ページネーションの大きさを変える関数
 * @param element - HTMLElement 現在表示中のスライドのHTML要素を引数に取ります。
 */
function activatePagination(element) {
  const currentActiveIndex = document.querySelector("#pagination .active");
  if (currentActiveIndex !== null) {
    currentActiveIndex.classList.remove("active");
  }
  const newActiveIndex = document.querySelector(`a[href='#${element.id}']`);
  newActiveIndex.classList.add("active");
}

前段のとおり、コアな部分はscroll-snap-typeプロパティで実現できます。しかし、現実的な場面で考えるとページネーションの設置も必要になるでしょう。ページネーションに関してもモダンなJavaScriptを使えば手軽に実装できます。

スクロール部分の実装

<div class="fullPageScroll"> ... </div>で囲まれた部分が全画面スクロール部分ですので、こちらにscroll-snap-typeのプロパティを適用します。

.fullPageScroll {
  width: 100%;
  height: 100vh;
  scroll-snap-type: y mandatory;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

大きさは横幅100%、縦幅100vhを指定して全画面を要素内スクロールの親としています。今回は縦スクロールなのでoverflow-y: autoを指定しています。(overflow-y: scrollでも可)

スクロールの厳密さについては、機能の仕様上、中途半端に止めたくないので、mandatoryを指定しています。最後の-webkit-overflow-scrolling: touchはiOSといったタッチデバイスへの要素内スクロールを慣性スクロールにするための指定です。

ページネーションの実装

ページネーションは現在どの部分が表示されているかを示す要素です。今回は画面右側に配置しています。

<nav id="pagination" class="pagination">
  <a id="pagination1" href="#section1"></a>
  <a id="pagination2" href="#section2"></a>
  <a id="pagination3" href="#section3"></a>
  <a id="pagination4" href="#section4"></a>
  <a id="pagination5" href="#section5"></a>
</nav>
.pagination a {
  display: block;
  width: 12px;
  height: 12px;
  margin: 24px 0;
  border-radius: 50%;
  background-color: #fcfcfc;
  transition: transform 0.2s;
}

.pagination a.active {
  transform: scale(1.8);
}

<a>タグで目的のスライドまでページ内遷移を設定しています。またactiveのクラス名が付与されると大きさが1.8倍になるよう指定しています。

現在表示中のスライドの取得

まずは現在表示中のページネーションを大きくさせるために、現在のスライドを取得するスクリプトを説明します。

// Intersection Observer
const sections = document.querySelectorAll(".section");
const observerRoot = document.querySelector(".fullPageScroll");
const options = {
  root: observerRoot,
  rootMargin: "-50% 0px",
  threshold: 0
};
const observer = new IntersectionObserver(doWhenIntersect, options);
sections.forEach(section => {
  observer.observe(section);
});

/**
 * 交差したときに呼び出す関数
 * @param entries - IntersectionObserverEntry IntersectionObserverが交差したときに渡されるオブジェクトです。
 */
function doWhenIntersect(entries) {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      activatePagination(entry.target);
    }
  });
}

現在のスライドはIntersection Observerを使って取得します。今回の全画面スクロールも要素内スクロールを使っているのでIntersection Observerが利用できます。APIの詳細は記事『JSでのスクロール連動エフェクトには Intersection Observerが便利』に紹介しています。

const observerRoot = document.querySelector(".fullPageScroll");
const options = {
  root: observerRoot,
  rootMargin: "-50% 0px",
  threshold: 0
};

オブザーバーのルート要素に要素内スクロールを設定している.fullPageScrollを指定しています。rootMargin"-50% 0px"を指定し、画面水平中央を交差検知にしています。こうすることで要素内スクロールを監視し、新しいスライドを表示したら(=画面中心をスライドを交差したら)コールバック関数activatePagination()を呼び、交差したHTML要素entry.targetを引数として渡します。

/**
 * ページネーションの大きさを変える関数
 * @param element - HTMLElement 現在表示中のスライドのHTML要素を引数に取ります。
 */
function activatePagination(element) {
  const currentActiveIndex = document.querySelector("#pagination .active");
  if (currentActiveIndex !== null) {
    currentActiveIndex.classList.remove("active");
  }
  const newActiveIndex = document.querySelector(`a[href='#${element.id}']`);
  newActiveIndex.classList.add("active");
}

すでにactiveのクラス名がついているページネーションがあれば削除します。次にactivatePagination()関数では受け取った引数のid名をelement.idで取得し、href属性にそのid名を持っているものを探します。そして、その要素にactiveクラス名を付与します。

これとさきほどのCSSを組み合わせれば、現在表示中のスライドのページネーションが大きくなります。

スムーススクロールの実装

ページネーションをクリックした時、目的のスライドまでスルスルっと移動したほうが気持ち良いですよね。そのためのスムーススクロールを実装します。

// スムーススクロール
const paginations = document.querySelectorAll(".pagination a");
paginations.forEach(pagination => {
  pagination.addEventListener("click", e => {
    e.preventDefault();
    const targetId = e.target.hash;
    const target = document.querySelector(targetId);
    target.scrollIntoView({ behavior: "smooth" });
  });
});

ページネーションの<a>タグそれぞれにclickのイベントリスナーを追加し、e.preventDefault()でデフォルトの挙動をキャンセルします。e.target.hash<a>タグに記述されていた飛び先を取得し、const target = document.querySelector(targetId)でその飛び先の要素を変数に格納します。

続いて、scrollIntoView()メソッドを使って飛び先までスクロールさせます。このとき、引数として{ behavior: "smooth" }を渡すことで、スムーススクロールを実現できます。

以上を組み合わせて、基本的なフルページスクロールの機能を実装できます。

IE11等への対応

scroll-snap-typeはIE11も対応していますが、Intersection ObserverはIE11はサポートしていません。またscrollIntoView()メソッドで、{ behavior: "smooth" }のオプションはSafariでは対応していません。そのためのポリフィル『Smooth Scroll behavior polyfill』や『Intersection Observer Polyfill』も読み込んでおくと良いでしょう。

<script src="smoothscroll.js"></script>
<script src="intersection-observer.js"></script>

なお、IE11はquerySelectorAll()で取得してきた要素に対してforEach()メソッドを直接使えないので、工夫が必要になります。Babelなどのツールを使うと良いでしょう。IE11対応のデモとソースコードは以下になります。

スライド内は比較的自由

この方法ではDOMを直接JavaScriptで生成したり、大きく内容を編集していたりしません。そのため、デモにもあるようにYouTube動画を埋め込んだり、スライド内にさらに要素内スクロールを設置したりもできます。

比較的自由にHTML・CSSを作成することができるので、デザイナーの要望も叶えやすいでしょう。

UXとしては配慮が必要

スクロールの挙動をJavaScriptなどで制御することはスクロールジャックと呼ばれ、操作をユーザーの意志から取り上げる行為になり、UX上の懸念点として挙げられています。この表現を使う際にはUX上問題ないかよく検討した上での導入をオススメします。