스크롤 스냅 실시간 피드백

개요

사용자는 캐러셀이나 갤러리와 같은 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 이벤트는 자동으로 실행되지 않습니다. 일회성 기하학적 검사를 실행하여 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가 원활하게 동기화된 상태를 유지합니다.