スクロール スナップ状態の同期

従来、UI の状態をスクロール可能なコンテナのスナップ位置と同期するには、複雑なスクロール イベント リスナー、スクロール オフセットの手動計算、交差オブザーバーが必要でした。scrollsnapchange イベントは、スクローラーが新しいスナップ ターゲットに落ち着いたことを検出するネイティブで効率的な方法を提供します。これは、サイドバーの同期や、目次内のアクティブなセクションのハイライト表示に役立ちます。

実装

1. CSS でスクロール スナップを構成する

ブラウザがスナップ ターゲットを追跡するには、コンテナに scroll-snap-type が定義され、scroll-snap-align を持つ子要素が必要です。目次を含む長い記事では、これを使用してセクション ヘッダーをビューポートの上部にスナップできます。

main {
    /* Enable scroll snapping on the container */  
  scroll-snap-type: y proximity;
  overflow-y: auto;
}

h2 {
  /* Define how headers align when snapped */
  scroll-snap-align: start;
}

2. スナップの変更をリッスンする

スクロール コンテナで scrollsnapchange イベントを使用すると、ユーザーがスクロールを終了し、ブラウザが新しい要素にスナップしたときに反応できます。この TOC デモでは、これを使用してサイドバーのアクティブなリンクをハイライト表示しています。

<!-- MANDATORY: Wrap table of contents links inside a proper navigation landmark -->
<nav aria-label="Table of contents">
  <ul>
    <li><a href="#section-1" aria-current="location">Section 1</a></li>
    <li><a href="#section-2">Section 2</a></li>
  </ul>
</nav>
const main = document.getElementById('main');
const links = document.querySelectorAll('nav a');

// The event fires when the scroller settles on a new snap target
main.addEventListener('scrollsnapchange', (event) => {
  // Use snapTargetBlock for vertical or snapTargetInline for horizontal
  const snappedHeader = event.snapTargetBlock;
  
  if (snappedHeader) {
    setSelectedParagraph(snappedHeader.id);
  }
});

ユーザー補助

目次では、サイドバーのリンクがアクティブなときに aria-current="true" または aria-current="location" が使用されていることを確認します。

また、scroll-snap-type に mandatory 値を使用する場合は注意が必要です。スナップポイント間のコンテンツが画面よりも長い場合、アクセスできなくなる可能性があります。

代替戦略

scrollsnapchange がサポートされていない場合は、IntersectionObserver を使用して、現在スクローラーの上部にある要素を検出します。これは scrollsnapchange とは異なる動作です。スクロールが落ち着いたときだけでなく、スクロール中にもトリガーされます。

// Feature detect support for scroll snap events
if (!('onscrollsnapchange' in HTMLElement.prototype)) {
  const observer = new IntersectionObserver(
    () => {
      // Each time the set of intersecting headers changes, find the top
      // header that is visible.
      const topEntry = [...headers].reduce((currentTop, header) => {
        // Use the bottom to handle scrolling up, when the top is still offscreen
        const {bottom} = header.getBoundingClientRect();
        // Don't match if the header's bottom is above the scrollport
        if (bottom < 0) return;
        if (!currentTop) return header;
        return bottom <
          currentTop.getBoundingClientRect().bottom
          ? header
          : currentTop;
      }, undefined);
      if (topEntry) setSelectedParagraph(topEntry.id);
    },
    { root: main, threshold: 0.9 // Adjust based on your use case },
  );

  // Observe all snap targets (e.g., section headers)
  document.querySelectorAll('h2').forEach(header => observer.observe(header));
}