スクロール スナップのリアルタイム フィードバック

概要

ユーザーは、カルーセルやギャラリーなどの UI 要素を操作するときに、すぐに視覚的なフィードバックが得られることを期待します。従来のスクロール スナップでは、スクロール ジェスチャーが完了して要素が落ち着いた後にのみフィードバックが提供されます。スクロール スナップ イベント(特に scrollsnapchanging)を使用すると、スクロール ジェスチャー中にリアルタイム フィードバックを提供し、ユーザーがタッチまたはマウスを離す前に、保留中のスナップ ターゲットをハイライト表示できます。

実装

1. scrollsnapchanging を聞き取る

scrollsnapchanging のイベント リスナーをスクロール コンテナにアタッチします。このイベントは、新しいスナップ ターゲットが選択される可能性が高いとブラウザが判断したときに発生します。

const container = document.querySelector('#gallery');
const thumbnails = document.querySelectorAll('.thumbnail');
const items = document.querySelectorAll('.gallery-item');

container.addEventListener('scrollsnapchanging', (event) => {
  // Highlight pending snap target during scroll for real-time feedback.
  const pendingTarget = event.snapTargetInline;
  const index = [...items].indexOf(pendingTarget);

  if (index === -1 || !thumbnails[index]) return;

  // Use lightweight class toggle to avoid layout thrashing during rapid events.
  // Note: aria-current is NOT toggled here. It tracks the settled "current"
  // item, which is updated in the scrollsnapchange handler below.
  thumbnails.forEach((thumb) => thumb.classList.remove('pending'));
  thumbnails[index].classList.add('pending');
});

この例では、ギャラリーが水平方向にスクロールするため、snapTargetInline を使用しています。スクロール コンテナが垂直方向にスクロールする場合は、代わりに snapTargetBlock を使用します。

2. scrollsnapchange を聞き取る

スクロール ジェスチャーが完了し、要素が実際にスナップされたときに状態を確定するには、scrollsnapchange イベントをリッスンします。これは、最終的なアクティブ状態を確立するために必要です。

container.addEventListener('scrollsnapchange', (event) => {
  // Promote pending state to active on scroll completion.
  const snappedTarget = event.snapTargetInline;
  const index = [...items].indexOf(snappedTarget);

  if (index === -1 || !thumbnails[index]) return;

  // Establish final active state and clean up pending.
  thumbnails.forEach((thumb) => {
    thumb.classList.remove('pending', 'active');
    thumb.removeAttribute('aria-current');
  });
  thumbnails[index].classList.add('active');
  thumbnails[index].setAttribute('aria-current', 'true');
});

3. 同期の初期状態

ページ読み込み時に、ブラウザによってスクロール位置が復元されることがあります(履歴トラバーサルやアンカーリンクなど)。scrollsnapchange イベントも scroll イベントも自動的には配信されません。1 回限りの幾何学的チェックを実行して、UI を初期スクロール位置と同期します。

// Note: For item.offsetLeft to be relative to the container, 
// the container MUST be the offsetParent (e.g., `position: relative`).
const findClosestItemIndex = () => {
  // Center-distance assumes scroll-snap-align: center on items.
  // For start-aligned snap, compare scrollLeft to item.offsetLeft directly.
  const containerCenter = container.scrollLeft + container.clientWidth / 2;
  let closestIndex = 0;
  let minDistance = Infinity;

  items.forEach((item, index) => {
    const itemCenter = item.offsetLeft + item.offsetWidth / 2;
    const distance = Math.abs(containerCenter - itemCenter);
    if (distance < minDistance) {
      minDistance = distance;
      closestIndex = index;
    }
  });
  return closestIndex;
};

const initActiveItem = () => {
  const closestIndex = findClosestItemIndex();
  if (!thumbnails[closestIndex]) return;

  thumbnails.forEach((thumb) => {
    thumb.classList.remove('pending', 'active');
    thumb.removeAttribute('aria-current');
  });
  thumbnails[closestIndex].classList.add('active');
  thumbnails[closestIndex].setAttribute('aria-current', 'true');
};

if (document.readyState === 'complete') {
  initActiveItem();
} else {
  window.addEventListener('load', initActiveItem, { once: true });
}

代替戦略

scrollsnapchanging をサポートしていないブラウザの場合、デフォルトでは、スクロール操作中に UI が迅速なフィードバックを提供せず、リンクされた UI がコンテンツと同期しなくなります。

必須: サポートされていないブラウザ用の代替手段を提供してください。そうしないと、リンクされた UI がコンテンツと同期しなくなります。

scrollsnapchanging のリアルタイム性をシミュレートする。scroll イベント リスナーと requestAnimationFrame、幾何学的距離の計算を組み合わせて、ユーザーがアクティブにスクロールしているときに最も近いスナップ ターゲットを特定します。

if ('onscrollsnapchanging' in Element.prototype) {
  // Use native scroll snap events
} else {
  // Fallback: use scroll + requestAnimationFrame for eager feedback
  // (assumes the same container, thumbnails, items defined in step 1)
  let scrollTimeout;
  let rafId = null;

  const promotePendingToActive = () => {
    const closestIndex = findClosestItemIndex();
    if (!thumbnails[closestIndex]) return;
    thumbnails.forEach((thumb) => {
      thumb.classList.remove('pending', 'active');
      thumb.removeAttribute('aria-current');
    });
    thumbnails[closestIndex].classList.add('active');
    thumbnails[closestIndex].setAttribute('aria-current', 'true');
  };

  container.addEventListener('scroll', () => {
    if (rafId) return;
    rafId = requestAnimationFrame(() => {
      rafId = null;
      const closestIndex = findClosestItemIndex();
      if (!thumbnails[closestIndex]) return;

      // DO NOT forget to clean up stale pending classes
      thumbnails.forEach((thumb) => thumb.classList.remove('pending'));
      thumbnails[closestIndex].classList.add('pending');
    });

    // Debounce fallback for browsers that don't support scrollend
    clearTimeout(scrollTimeout);
    scrollTimeout = setTimeout(promotePendingToActive, 100);
  }, { passive: true });

  // Fallback: use Baseline `scrollend` event to promote pending to active cleanly where supported
  container.addEventListener('scrollend', () => {
    clearTimeout(scrollTimeout);
    promotePendingToActive();
  });
}

幾何学的な scroll + requestAnimationFrame フォールバックは、プログラムによるスクロールを正しく処理するなど、ネイティブのスナップ予測の動作を忠実にエミュレートします。scrollIntoView などの関数は、実行中に scroll イベントを自然に発生させるため、追加のカスタム ロジックを必要とせずに、スクロール アニメーション全体で UI がスムーズに同期されます。