CSS position:sticky イベント

Eric Bidelman 氏

要約

次のアプリでは scroll イベントは必要としないかもしれません。ここでは、IntersectionObserver を使用して、position:sticky 要素が固定されたとき、または固定されなくなったときにカスタム イベントを送信する方法を説明します。すべてスクロール リスナーを使用せずに済みます。それを証明するデモもあります。

デモを見る | ソース

sticky-change イベントのご紹介

CSS のスティッキー位置を使用する際の現実的な制限の 1 つは、プロパティがアクティブになったことを知るためのプラットフォーム シグナルが提供されないことです。つまり、要素がスティッキーになったときや、スティッキーからなくなったタイミングを認識するイベントはありません。

次の例では、親コンテナの上部から 10 ピクセルから <div class="sticky"> を修正しています。

.sticky {
  position: sticky;
  top: 10px;
}

要素がマークされた時点でブラウザに通知できれば便利ですよね? そう考えているのは私だけではありませんposition:sticky のシグナルによって、次のようなユースケースが実現する可能性があります。

  1. 固定したバナーにドロップ シャドウを適用します。
  2. ユーザーがコンテンツを一通り読んだら、アナリティクス ヒットを記録して進行状況を確認します。
  3. ユーザーがページをスクロールしたら、フローティング TOC ウィジェットを現在のセクションに更新します。

こうしたユースケースを念頭に置き、position:sticky 要素が固定されたときに発動するイベントを作成することを最終目標にしました。これを sticky-change イベントとしましょう。

document.addEventListener('sticky-change', e => {
  const header = e.detail.target;  // header became sticky or stopped sticking.
  const sticking = e.detail.stuck; // true when header is sticky.
  header.classList.toggle('shadow', sticking); // add drop shadow when sticking.

  document.querySelector('.who-is-sticking').textContent = header.textContent;
});

demo では、このイベントを使用して、修正時にドロップ シャドウをヘッダーで表示します。また、ページ上部の新しいタイトルも更新されます。

このデモでは、スクロール イベントなしでエフェクトが適用されます。

スクロール イベントを使用しないスクロール効果

ページの構造。
ページの構造。

本投稿の残りの部分を通して、これらの名称を参照できるように、いくつかの用語について説明します。

  1. スクロール コンテナ - 「ブログ投稿」のリストを含むコンテンツ領域(表示されるビューポート)。
  2. ヘッダー - position:sticky が含まれる各セクションの青いタイトル。
  3. 固定セクション - 各コンテンツ セクション。固定されたヘッダーの下でスクロールするテキスト。
  4. 「スティッキー モード」 - position:sticky が要素に適用されている場合。

どのヘッダーが「スティッキー モード」になるかを把握するには、スクロール コンテナのスクロール オフセットを決定する方法が必要です。これにより、現在表示されているヘッダーを計算することができます。ただし、scroll イベントがないと、かなり複雑になります。もう一つの問題は、position:sticky が修正されたときにレイアウトから要素が削除されることです。

そのため、スクロール イベントがなければ、ヘッダーでレイアウト関連の計算を実行することができなくなります

スクロール位置を決定するための Dumby DOM の追加

ここでは、scroll イベントの代わりに IntersectionObserver を使用して、headersがスティッキー モードの開始と終了のタイミングを特定します。各固定セクションに 2 つのノード(標識)を、上部と下部に 1 つずつ追加すると、スクロール位置を決定するための地点として機能します。これらのマーカーがコンテナに出入りすると、表示状態が変化し、Intersection Observer がコールバックを呼び出します。

標識要素なし
非表示の標識要素。

上下の 4 つのケースに対応する 2 つの標識が必要です。

  1. 下へのスクロール - ヘッダーは、トップ センチネルがコンテナの上部を横切ると固定されます。
  2. 下にスクロール - ヘッダーは、セクションの下部に到達し、下部の標識がコンテナの上部を横切るため、スティッキー モードを終了します。
  3. 上方向へのスクロール - 上部のセンチネルが上部からビューに戻ると、ヘッダーは固定モードを終了します。
  4. 上方向にスクロール - 下部のセンチネルが上からビューに戻ると、ヘッダーは固定されます。

1 ~ 4 のスクリーンキャストを発生順に見ることをおすすめします。

Intersection Observer は、標識がスクロール コンテナに出入りするときにコールバックを呼び出します。

CSS

見張り役は各セクションの上部と下部に配置されています。.sticky_sentinel--top はヘッダーの上部に、.sticky_sentinel--bottom はセクションの下部にあります。

しきい値に達しています。
上下のセンチネル要素の位置。
:root {
  --default-padding: 16px;
  --header-height: 80px;
}
.sticky {
  position: sticky;
  top: 10px; /* adjust sentinel height/positioning based on this position. */
  height: var(--header-height);
  padding: 0 var(--default-padding);
}
.sticky_sentinel {
  position: absolute;
  left: 0;
  right: 0; /* needs dimensions */
  visibility: hidden;
}
.sticky_sentinel--top {
  /* Adjust the height and top values based on your on your sticky top position.
  e.g. make the height bigger and adjust the top so observeHeaders()'s
  IntersectionObserver fires as soon as the bottom of the sentinel crosses the
  top of the intersection container. */
  height: 40px;
  top: -24px;
}
.sticky_sentinel--bottom {
  /* Height should match the top of the header when it's at the bottom of the
  intersection container. */
  height: calc(var(--header-height) + var(--default-padding));
  bottom: 0;
}

Intersection Observer を設定する

Intersection Observer は、ターゲット要素とドキュメント ビューポートまたは親コンテナの交差点の変更を非同期で監視します。この例では、親コンテナとの交差をモニタリングします。

おすすめのソースは IntersectionObserver です。各標識は、スクロール コンテナ内の交差点の可視性をオブザーバーするために IntersectionObserver を取得します。標識が可視ビューポートまでスクロールすると、ヘッダーが固定された、または固定されなくなったことがわかります。同様に、標識がビューポートを出るときです。

まず、ヘッダーとフッターのセンチネルのオブザーバーを設定します。

/**
 * Notifies when elements w/ the `sticky` class begin to stick or stop sticking.
 * Note: the elements should be children of `container`.
 * @param {!Element} container
 */
function observeStickyHeaderChanges(container) {
  observeHeaders(container);
  observeFooters(container);
}

observeStickyHeaderChanges(document.querySelector('#scroll-container'));

次に、.sticky_sentinel--top 要素がスクロール コンテナの上部を(どちらの方向にも)通過したときに起動するオブザーバーを追加しました。observeHeaders 関数は上位の標識を作成し、各セクションに追加します。オブザーバーは、標識のコンテナの上面との交点を計算し、ビューポートに入るかそこから出るかを判断します。この情報により、セクション ヘッダーが固定されているかどうかが決まります。

/**
 * Sets up an intersection observer to notify when elements with the class
 * `.sticky_sentinel--top` become visible/invisible at the top of the container.
 * @param {!Element} container
 */
function observeHeaders(container) {
  const observer = new IntersectionObserver((records, observer) => {
    for (const record of records) {
      const targetInfo = record.boundingClientRect;
      const stickyTarget = record.target.parentElement.querySelector('.sticky');
      const rootBoundsInfo = record.rootBounds;

      // Started sticking.
      if (targetInfo.bottom < rootBoundsInfo.top) {
        fireEvent(true, stickyTarget);
      }

      // Stopped sticking.
      if (targetInfo.bottom >= rootBoundsInfo.top &&
          targetInfo.bottom < rootBoundsInfo.bottom) {
       fireEvent(false, stickyTarget);
      }
    }
  }, {threshold: [0], root: container});

  // Add the top sentinels to each section and attach an observer.
  const sentinels = addSentinels(container, 'sticky_sentinel--top');
  sentinels.forEach(el => observer.observe(el));
}

オブザーバーは threshold: [0] で構成されているため、標識が表示されるとすぐにコールバックが呼び出されます。

このプロセスは下部のセンチネル(.sticky_sentinel--bottom)の場合も同様です。フッターがスクロール コンテナの下部を通過したときに起動される 2 つ目のオブザーバーが作成されます。observeFooters 関数は、標識ノードを作成し、各セクションに接続します。オブザーバーは、標識とコンテナの底部の交差を計算し、コンテナに入るかそこから出るかを判断します。この情報から、セクション ヘッダーが固定されているかどうかを判断します。

/**
 * Sets up an intersection observer to notify when elements with the class
 * `.sticky_sentinel--bottom` become visible/invisible at the bottom of the
 * container.
 * @param {!Element} container
 */
function observeFooters(container) {
  const observer = new IntersectionObserver((records, observer) => {
    for (const record of records) {
      const targetInfo = record.boundingClientRect;
      const stickyTarget = record.target.parentElement.querySelector('.sticky');
      const rootBoundsInfo = record.rootBounds;
      const ratio = record.intersectionRatio;

      // Started sticking.
      if (targetInfo.bottom > rootBoundsInfo.top && ratio === 1) {
        fireEvent(true, stickyTarget);
      }

      // Stopped sticking.
      if (targetInfo.top < rootBoundsInfo.top &&
          targetInfo.bottom < rootBoundsInfo.bottom) {
        fireEvent(false, stickyTarget);
      }
    }
  }, {threshold: [1], root: container});

  // Add the bottom sentinels to each section and attach an observer.
  const sentinels = addSentinels(container, 'sticky_sentinel--bottom');
  sentinels.forEach(el => observer.observe(el));
}

オブザーバーは threshold: [1] で構成されているため、ノード全体がビュー内に入ったときにコールバックが起動します。

最後に、sticky-change カスタム イベントを起動して標識を生成するユーティリティが 2 つあります。

/**
 * @param {!Element} container
 * @param {string} className
 */
function addSentinels(container, className) {
  return Array.from(container.querySelectorAll('.sticky')).map(el => {
    const sentinel = document.createElement('div');
    sentinel.classList.add('sticky_sentinel', className);
    return el.parentElement.appendChild(sentinel);
  });
}

/**
 * Dispatches the `sticky-event` custom event on the target element.
 * @param {boolean} stuck True if `target` is sticky.
 * @param {!Element} target Element to fire the event on.
 */
function fireEvent(stuck, target) {
  const e = new CustomEvent('sticky-change', {detail: {stuck, target}});
  document.dispatchEvent(e);
}

完了です!

最後のデモ

position:sticky が指定された要素が修正されたときにカスタム イベントを作成し、scroll イベントを使用せずにスクロール効果を追加しました。

デモを見る | ソース

まとめ

長年にわたって開発されてきた scroll イベントベースの UI パターンの一部の代わりに、IntersectionObserver が役立つのではないかと考えたくなります。結論はイエスかノーかです。IntersectionObserver API のセマンティクスのため、すべてに使うことは困難です。これまで見てきたように、いくつかの興味深い手法に応用できます。

スタイルの変更を検出する別の方法としては、

そうではありません。必要なのは、DOM 要素のスタイルの変化を監視することでした。残念ながら、ウェブ プラットフォーム API にはスタイルの変更を監視できる機能がありません。

MutationObserver は論理的な最初の選択肢ですが、ほとんどの場合は機能しません。たとえばこのデモでは、sticky クラスが要素に追加されたときにはコールバックを受け取りますが、要素の計算されたスタイルが変更されたときにはコールバックを受信しません。sticky クラスは、ページの読み込み時にすでに宣言されていることを思い出してください。

将来的には、要素の計算済みスタイルに対する変更を監視するために、Mutation Observer の拡張機能「Style Mutation Observer」が役立つ可能性があります。position: sticky