스크롤 스냅 상태 동기화

기존에는 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));
}