スクロールスナップイベントの使用

CSS スクロールスナップモジュールでは、 2 つのスクロールスナップイベント、 scrollsnapchangingscrollsnapchange が定義されています。これらは、ブラウザーが新しいスクロールスナップのターゲットがそれぞれ待機中または選択されたと判断したことに応じて、それぞれJavaScriptを実行できるようにします。

このガイドでは、これらのイベントの概要と完全な例を紹介します。

イベント概要

スクロールスナップイベントは、スクロールスナップの対象となり得るものが含まれているスクロールコンテナーに対して設定されます。

  • scrollsnapchanging イベントは、現在のスクロール操作が終わり、新しいスクロールスナップ対象が選択されるとブラウザーが判断したときに発行されます。これは待機中のスクロールスナップターゲットです。 仕様上、このイベントはスクロール操作中に、ユーザーが新しいスナップターゲット候補に移動するたびに発行されます。 スクロール操作ごとに scrollsnapchanging イベントが複数回発生する場合もありますが、複数のスナップターゲットに移動するスクロール操作では、すべてのスナップターゲット候補に対してイベントが発生するわけではありません。 むしろ、最後にスナップが確定する可能性のあるターゲットに対してのみイベントが発生します。

  • scrollsnapchange イベントは、スクロール操作の終わりに新しいスクロールスナップのターゲットが選択された際に発行されます。 具体的には、このイベントはスクロール操作が完了した際に発行されますが、新しいスナップのターゲットが選択された場合のみです。 このイベントは、 scrollend イベントが発行される直前に発行されます。

実際に 2 つのイベントを表示させる例を見ていきましょう(この記事の後半で、このイベントがどのように構築されるかを見ていきます)。

掲載されているボックスのリストを上下にスクロールしてみてください。

  • スクロール操作を離さずに、コンテナー内でゆっくりと上下にスクロールしてみてください。例えば、タッチ画面の端末やトラックパッドのスクロールエリア上で指をドラッグしたり、スクロールバー上でマウスボタンを押し下げたままマウスを移動させたりしてみてください。ボックスの上に移動すると、ボックスの色が濃い灰色に変わり、ボックスから離れると元の色に戻ります。これが、 scrollsnapchanging イベントの動作です。
  • 次に、スクロール操作を解除してみてください。スクロール位置の最も近いボックスが紫色に変わり、テキストが白くなります。このアニメーションは、 scrollsnapchange イベントが発火したときに発生します。
  • 最後に、高速スクロールを試してみてください。例えば、画面に強く指を弾くようにして、いくつかの潜在的なターゲットを渡すと、スクロールコンテナー内でさらに下のターゲット付近で静止し始めます。スクロールが遅くなり始める際に、 scrollsnapchanging イベントが1回だけ発行され、その後、 scrollsnapchange イベントが発行され、選択したスナップ対象が紫色に変わります。

SnapEvent イベントオブジェクト

上記 2 つのイベントは、 SnapEvent イベントオブジェクトを共有しています。ここには、スクロールスナップイベントがどのように動作するかを示す 2 つのプロパティがあります。

  • snapTargetBlock は、イベントが発行された際に、ブロック方向にスナップされた要素への参照を返します。または、スクロールスナップがインライン方向のみに発生し、ブロック方向にスナップされる要素がない場合は null を返します。
  • snapTargetInline は、イベントが発行された際に、インライン方向にスナップされた要素への参照を返します。または、スクロールスナップがブロック方向のみに発生し、インライン方向にスナップされる要素がない場合は null を返します。

これらのプロパティにより、イベントハンドラー関数は、スナップされた要素(scrollsnapchange の場合)またはスクロール操作がこれで完了した場合にスナップされるはずである要素(scrollsnapchanging の場合)を、 1 次元および 2 次元で報告することができます。例えば、 style プロパティを使用してスタイルを直接設定したり、このスタイルシートはスタイルを定義しているクラスを設定したりするなど、これらの要素を任意の方法で操作することができます。

CSS scroll-snap-type との関連

SnapEvent で利用できるプロパティ値は、スクロールコンテナーで設定された CSS の scroll-snap-type プロパティの値に直接対応しています。

  • スナップ軸を block(または現在の書字方向で block と等価な物理軸値)として指定した場合、snapTargetBlock のみ要素参照を返します。
  • スナップ軸を inline(または現在の書き込みモードで inline と同等となる物理軸値)として指定した場合、snapTargetInline のみ要素参照を返します。
  • スナップ軸を both と指定した場合、snapTargetBlocksnapTargetInline は要素参照を返します。

一次元のスクローラーの処理

水平スクロールバーを扱っている場合、コンテンツの writing-mode が横書きである場合は、スナップされた要素が変更されると、イベントオブジェクトの snapTargetInline プロパティのみが変更され、 writing-mode が縦書きである場合は、 snapTargetBlock プロパティが変更されます。

逆に、垂直スクローラーを扱う場合は、コンテンツの書字方向が横書きに指定されている場合はスナップされた要素が変更されると snapTargetBlock プロパティのみが変更され、コンテンツの書字方向に縦書が指定されている場合は snapTargetInline プロパティが変更されます。

どちらの場合も、両者の変化しないというプロパティは null です。

例えば、典型的な一次元スクロールのスナップイベントハンドラー関数を表示させてみましょう。

js
scrollingElem.addEventListener("scrollsnapchange", (event) => {
  event.snapTargetBlock.className = "select-section";
});

このスニペットでは、スナップターゲットが内部に表示されるブロック方向のスクロールコンテナー要素に、 scrollsnapchange ハンドラー関数が設定されています。イベントが発行されると、 snapTargetBlock 要素に select-section クラスが設定されます。このクラスは、新たに選択されたスナップターゲットを、選択されたように見せるスタイル設定に使用することができます(例えば、アニメーションを使用するなど)。

二次元のスクローラーの処理

横書きと縦書きのスクロールを扱う場合は、コードが複雑になります。これは、 snapTargetBlock プロパティと snapTargetInline プロパティの値がどちらも要素の参照を返すためです(どちらも null を返すことはありません)。また、どちらも、スクロールの方向とコンテンツの writing-mode によって値が変更されます。

  • スクローラーが水平方向にスクロールする場合、コンテンツの writing-mode が横書きである場合は、スナップされた要素が変更されると snapTargetInline プロパティが変更され、コンテンツの writing-mode が縦書きである場合は snapTargetBlock プロパティが変更されます。
  • スクローラーが垂直方向にスクロールする場合、コンテンツの writing-mode が横書きである場合は、スナップされた要素が変更されると snapTargetBlock プロパティが変更され、コンテンツの writing-mode が縦書きである場合は snapTargetInline プロパティが変更されます。

これに対処するには、snapTargetBlock 要素と snapTargetInline 要素のどちらが変更されたのかを追跡する必要があるでしょう。 例を見てみましょう。

js
const prevState = {
  snapTargetInline: "s1",
  snapTargetBlock: "s1",
};

scrollingElem.addEventListener("scrollsnapchange", (event) => {
  if (!(prevState.snapTargetBlock === event.snapTargetBlock.id)) {
    console.log(
      `コンテナーはブロック方向に要素 ${event.snapTargetBlock.id} までスクロールしました`,
    );
  }

  if (!(prevState.snapTargetInline === event.snapTargetInline.id)) {
    console.log(
      `コンテナーはインライン方向に要素 ${event.snapTargetBlock.id} までスクロールしました`,
    );
  }

  prevState.snapTargetBlock = event.snapTargetBlock.id;
  prevState.snapTargetInline = event.snapTargetInline.id;
});

このスニペットでは、まず、前の snapTargetBlock および snapTargetInline 要素の ID を持つるオブジェクト (prevState) を定義します。

イベントハンドラー関数では、 if 文を使用して、以下を検査します。

  • prevState.snapTargetBlock の ID が現在の event.snapTargetBlock 要素の ID であること。
  • prevState.snapTargetInline の ID が現在の event.snapTargetInline 要素の ID であること。

値が異なる場合、スクロールバーがその方向(ブロックまたはインライン)にスクロールされたということになり、そのことを示すメッセージをコンソールにログ出力します。例えば、スナップされた要素を何らかの方法でスタイル設定し、スナップされたことを示すことができます。

それから、 prevState.snapTargetBlock および prevState.snapTargetInline の値を更新し、イベントハンドラーが次に実行されたときのための準備をします。

この記事の残りの部分では、スクロールスナップが完了したときのイベントの例をいくつか見ていきます。それぞれの節の終わりにあるライブレンダリング版で実際に試すことができます。

一次元のスクローラーの例

この例では、垂直スクロールする <main> 要素に複数の明るい灰色の <section> 要素が含まれており、これらはすべてスクロールスナップのターゲットとなっています。新しいスナップターゲットが待機状態になると、濃い灰色に変わります。新しいスナップターゲットが選択されると、白いテキストとともにスムーズに紫色にアニメーションします。前回選択されていたスナップターゲットが異なる場合は、黒いテキストとともにスムーズに灰色に戻ります。

HTML

この例の HTML は単一の <main> 要素です。ページの容量を節約するために、後で JavaScript で <section> 要素を動的に追加します。

html
<main></main>

CSS

CSS では、まず <main> 要素に太い黒の border と固定された width および height を設定します。 また、 overflow の値を scroll に設定し、コンテンツがはみ出した場合は非表示にしてスクロールできるようにします。さらに、 scroll-snap-typeblock mandatory に設定し、ブロック方向のスナップターゲットのみが常にスナップされるようにします。

css
main {
  border: 3px solid black;
  width: 250px;
  height: 450px;
  overflow: scroll;
  scroll-snap-type: block mandatory;
}

それぞれの <section> 要素には、 margin50px で与えられており、 <section> 要素を区切り、スクロールの吸着動作をより明確にさせています。次に、 scroll-snap-aligncenter に設定し、各吸着ターゲットの中心に吸着させたいことを指定します。最後に、吸着ターゲットが選択された、または選択待ちの状態になった際に適用されるスタイル変更をスムーズにアニメーション化するために、 transition を適用します。

css
section {
  margin: 50px auto;
  scroll-snap-align: center;
  transition: 0.5s ease;
}

上記のスタイル変更は、 <section> 要素に適用されるクラスを JavaScript を通じて適用されます。 select-section クラスは選択を意味するものとして適用されます。これにより、紫色の背景と白いテキスト色が設定されます。 pending クラスは選択待機中のスナップターゲットを意味するものとして適用されます。これにより、選択待機中のターゲットの背景色が濃い灰色に設定されます。

css
.pending {
  background-color: #ccc;
}

.select-section {
  background: purple;
  color: white;
}

JavaScript

JavaScriptでは、まず <main> 要素への参照を取得し、生成する <section> 要素の数(この場合は21)と、カウントを開始する変数を定義します。次に、 while ループを使用して <section> 要素を生成し、各要素に子要素として h2 タグを追加し、そのテキストとして Section と現在の n の値を表示します。

js
const mainElem = document.querySelector("main");
const sectionCount = 21;
let n = 1;

while (n <= sectionCount) {
  mainElem.innerHTML += `
    <section>
      <h2>Section ${n}</h2>
    </section>
  `;
  n++;
}

それでは、 scrollsnapchanging イベントのハンドラー関数を見てみましょう。 <main> 要素の子(すなわち <section> 要素すべて)は、待機中のスナップターゲット選択となります。

  1. 以前に pending クラスが適用されていた要素がないか確認し、該当する場合はそれを削除します。これは、現在の待機中の対象のみに pending クラスが適用され、濃い灰色に色付けされるようにするためです。以前待機中だったが、現在は待機中でない対象にスタイルを維持させたくないからです。
  2. snapTargetBlock プロパティで参照される要素(これは、 <section> 要素のうちの 1 つ)に pending のクラスを指定すると、濃い灰色に変わります。
js
mainElem.addEventListener("scrollsnapchanging", (event) => {
  const previousPending = document.querySelector(".pending");
  if (previousPending) {
    previousPending.classList.remove("pending");
  }

  event.snapTargetBlock.classList.add("pending");
});

メモ: このデモでは、イベントオブジェクトのプロパティである snapTargetInline を気にする必要はありません。 なぜなら、このデモでは垂直スクロールのみを使用しており、またデモでは横書きモードを使用しているため、 snapTargetBlock の値のみが変更されるからです。 この場合、 snapTargetInline は常に null を返します。

スクロール操作が終了し、 <section> 要素が実際にスナップ先として選択されると、 scrollsnapchange イベントハンドラー関数が実行されます。これは、

  1. 前回、スナップ対象が選択されていたかどうか、つまり、前回、 select-section クラスが要素に適用されていたかどうかを調べます。 該当する場合は、除去します。
  2. select-section クラスを snapTargetBlock プロパティで参照する <section> 要素に適用し、選択されたスナップ対象に選択アニメーションが存在するようにします。
js
mainElem.addEventListener("scrollsnapchange", (event) => {
  const currentlySnapped = document.querySelector(".select-section");
  if (currentlySnapped) {
    currentlySnapped.classList.remove("select-section");
  }

  event.snapTargetBlock.classList.add("select-section");
});

結果

スクロールコンテナー内で上下にスクロールし、上記で説明されている動作を観察してみてください。

二次元のスクローラーの例

この例は、複数の明るい灰色の <section> 要素(すべてスナップターゲット)を含む、水平および垂直スクロールの <main> 要素という機能を除いては、前述の例と同様です。

この例の HTML は前回と同じで、単一の <main> 要素です。

CSS

この例の CSS は、前回の例の CSS と類似しています。最も大きな違いは以下の通りです。

最初の <main> 要素のスタイルを見てみましょう。 <section> 要素をグリッドレイアウトで配置したいので、 CSS グリッドレイアウトを使用して、 7 列で表示されるように、 grid-template-columns の値に repeat(7, 1fr) を使用します。また、 <section> 要素の周囲の余白を指定するために、 <main> 要素の paddinggap を設定し、 <section> 要素のマージンではなくします。

最後に、この例では両方向にスクロールするので、 scroll-snap-typeboth mandatory に設定し、ブロック方向とインライン方向のスナップ対象を常にスナップするようにします。

css
main {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  padding: 100px;
  gap: 50px;
  overflow: scroll;
  border: 3px solid black;
  width: 350px;
  height: 350px;

  scroll-snap-type: both mandatory;
}

次に、この例ではトランジションの代わりに CSS アニメーションを使用します。これによりコードは複雑になりますが、適用されるアニメーションをより細かく制御することができます。

最初に、スナップターゲットの選択が行われた、または待機中であることを示すシグナルに適用されるクラスを定義します。 select-section クラスと deselect-section クラスは、選択または選択解除を示すキーフレームアニメーションを適用します。 pending クラスは、待機中のスナップターゲット選択を示すために適用されます(例えば、前回のように、選択部分に濃い灰色の背景を適用します)。

@keyframes は、それぞれ灰色の背景と黒(既定)のテキスト色から紫色の背景と白のテキスト色にアニメーション化します。後者のアニメーションは最初のものと多少異なります。また、 opacity を使用してフェードアウト/フェードイン効果を作成します。

css
.select-section {
  animation: select 0.8s ease forwards;
}

.deselect-section {
  animation: deselect 0.8s ease forwards;
}

.pending {
  background-color: #ccc;
}

@keyframes select {
  from {
    background: #eee;
    color: black;
  }

  to {
    background: purple;
    color: white;
  }
}

@keyframes deselect {
  0% {
    background: purple;
    color: white;
    opacity: 1;
  }

  80% {
    background: #eee;
    color: black;
    opacity: 0.1;
  }

  100% {
    background: #eee;
    color: black;
    opacity: 1;
  }
}

JavaScript

JavaScript では、前の例とほぼ同じ方法で始めますが、今回は 49 個の <section> 要素を生成し、それぞれに s に現在の n の値を足した ID を割り当てて、後で追跡できるようにします。 上記で指定した CSS グリッドレイアウトでは、7 つの <section> 要素で 7 つの列が構成されます。

js
const mainElem = document.querySelector("main");
const sectionCount = 49;
let n = 1;

while (n <= sectionCount) {
  mainElem.innerHTML += `
    <section id="s${n}">
      <h2>Section ${n}</h2>
    </section>
  `;
  n++;
}

次に、prevStateと呼ばれるオブジェクトを指定します。これにより、この点で以前に選択されていたスナップターゲットを追跡することができます。そのプロパティには、以前のインラインおよびブロックスナップターゲットの ID が保存されています。これは、イベントハンドラーが発行されるたびに、新しいブロックターゲットまたは新しいインラインターゲットにスタイルを適用する必要があるかどうかを判断する上で重要です。

js
const prevState = {
  snapTargetInline: "s1",
  snapTargetBlock: "s1",
};

例えば、このスクロールコンテナーがスクロールされ、新しい SnapEvent.snapTargetBlock 要素の ID が変更された(prevState.snapTargetBlock に格納されている ID と等しくない)が、新しい SnapEvent.snapTargetInline 要素の ID は、 prevState.snapTargetInline に格納されている ID と同じままであったとします。これは、ブロック方向で新しいスナップ対象に移動したということなので、 SnapEvent.snapTargetBlock をスタイル設定すべきですが、インライン方向では新しいスナップ対象が移動していないので、 SnapEvent.snapTargetInline にスタイル設定すべきではありません。

今回は、 scrollsnapchange イベントハンドラー関数を最初に説明します。この関数では、次の処理を行います。

  1. 前回選択された <section> 要素のスナップターゲット(select-section クラスがあることで示される)には deselect-section クラスを設定し、選択解除のアニメーションを表示するようにします。前回スナップターゲットが選択されていなかった場合は、 select-section クラスを DOM の最初の <section> に適用し、ページが最初に読み込まれた際に選択されているように表示させます。
  2. 前回選択したスナップ対象 ID と今回選択したスナップ対象 ID を、ブロック選択とインライン選択の両方について比較します。 両者が異なっている場合、選択が変更されたことを示します。そのため、適切なスナップ対象に select-section クラスを適用し、視覚的にこのことを示します。
  3. prevState.snapTargetBlockprevState.snapTargetInline を、先ほど選択したスクロールスナップ対象の ID と等しくなるように更新します。これにより、次にイベントが発行された際に、前回選択したものが対象となります。
js
mainElem.addEventListener("scrollsnapchange", (event) => {
  if (document.querySelector(".select-section")) {
    document.querySelector(".select-section").className = "deselect-section";
  } else {
    document.querySelector("section").className = "select-section";
  }

  if (!(prevState.snapTargetBlock === event.snapTargetBlock.id)) {
    event.snapTargetBlock.className = "select-section";
  }

  if (!(prevState.snapTargetInline === event.snapTargetInline.id)) {
    event.snapTargetInline.className = "select-section";
  }

  prevState.snapTargetBlock = event.snapTargetBlock.id;
  prevState.snapTargetInline = event.snapTargetInline.id;
});

scrollsnapchanging イベントハンドラー関数が呼び出された場合、次のようにします。

  1. 前回 pending クラスが指定された要素から、そのクラスが除去され、現在の待機対象のみに pending クラスが指定され、濃い灰色に色付けされます。
  2. 現在待機中の要素に pending クラスを指定すると、濃い灰色に変わりますが、select-section クラスを保有していない場合のみです。新しい対象が実際に選択されるまでは、以前に選択された対象には紫色の選択スタイル設定を維持させたいからです。また、if 文には、変更されたのがインラインまたはブロックの待機中のスナップ対象のどちらであるかによって、スタイル設定をそのいずれかだけに限定するための追加のチェックが含まれます。この場合も、前回と今回のスナップ対象をそれぞれ比較します。
js
mainElem.addEventListener("scrollsnapchanging", (event) => {
  const previousPending = document.querySelector(".pending");
  if (previousPending) {
    previousPending.className = "";
  }

  if (
    !(event.snapTargetBlock.className === "select-section") &&
    !(prevState.snapTargetBlock === event.snapTargetBlock.id)
  ) {
    event.snapTargetBlock.className = "pending";
  }

  if (
    !(event.snapTargetInline.className === "select-section") &&
    !(prevState.snapTargetInline === event.snapTargetInline.id)
  ) {
    event.snapTargetInline.className = "pending";
  }
});

結果

スクロールコンテナー内で水平および垂直方向にスクロールし、上記で説明されている動作を監視してみてください。

DocumentWindow のスクロールスナップイベント

この記事では、 Element インターフェイスで発生するスクロールスナップイベントについて説明しましたが、同じイベントは Document および Window オブジェクトでも発行されます。次のものを参照してください。

これらは、 Element 版とほぼ同様に動作しますが、HTML 文書全体をスクロールスナップコンテナーとして設定する必要がある点が異なります(つまり、 scroll-snap-type<html> 要素に設定されている)。

例えば、上記で見てきた例と同様の例を挙げると、重要なコンテンツを含む <main> 要素を取得した場合、

html
<main>
  <!-- 重要なコンテンツ -->
</main>

<main> 要素は、例えば、 CSS プロパティの組み合わせを使用してスクロールコンテナーにすることができます。

css
main {
  width: 250px;
  height: 450px;
  overflow: scroll;
}

次に、 scroll-snap-type プロパティを <html> 要素に指定することで、スクロールコンテンツにスクロールスナップ動作を実装することができます。

css
html {
  scroll-snap-type: block mandatory;
}

次の JavaScript のスニペットは、 scrollsnapchange イベントが、 <main> 要素の子が新しく選択されたスナップターゲットになったときに HTML 文書上で発行されるようにします。 ハンドラー関数では、 selected クラスを SnapEvent.snapTargetBlock で参照される子に設定しています。このクラスは、イベントが発行されたときに、選択されたように(アニメーションなどで)見えるように、スタイルを設定するために使用することができます。

js
document.addEventListener("scrollsnapchange", (event) => {
  event.snapTargetBlock.classList.add("selected");
});

代わりに Window でイベントを発行することで、同じ機能を実現できます。

js
window.addEventListener("scrollsnapchange", (event) => {
  event.snapTargetBlock.classList.add("selected");
});

関連情報