프로그래매틱 스크롤 완료 후 효과 트리거

프로그래매틱 방식으로 섹션이나 요소로 스크롤할 때 (특히 behavior: 'smooth' 사용 시) 강조 표시 상태를 깜박이거나, 항목 애니메이션을 시작하거나, 포커스를 이동하는 등의 후속 작업을 즉시 트리거하면 타겟이 아직 화면 밖에 있거나 스크롤 중간에 있는 동안 효과가 실행됩니다.

이전에는 개발자가 setTimeout()를 사용하여 스크롤 지속 시간을 추측해야 했습니다(긴 스크롤에서는 너무 일찍 실행되고 짧은 스크롤에서는 너무 늦게 실행됨). 또는 대체 타이머를 사용하여 scrollend 이벤트 리스너를 Promise에 수동으로 래핑해야 했습니다 (타겟이 이미 뷰에 있고 스크롤이 발생하지 않으면 scrollend가 실행되지 않기 때문).

Element (scrollIntoView(), scroll(), scrollTo(), scrollBy()) 및 Window (scroll(), scrollTo(), scrollBy())의 프로그래매틱 스크롤 메서드는 스크롤 작업이 완료되면 확인되는 Promise를 반환합니다. 타겟이 이미 요청된 스크롤 위치에 있고 스크롤이 필요하지 않은 경우 반환된 Promise는 중단 없이 즉시 해결됩니다.

구현

  1. 핸들러를 async로 표시: 스크롤을 트리거하는 이벤트 리스너 또는 탐색 핸들러에 async 함수를 사용합니다.
  2. 스크롤 메서드를 직접 대기: await element.scrollIntoView({ behavior: 'smooth', ... }) (또는 await element.scrollTo(...), await window.scrollTo(...) 등)를 호출합니다. 브라우저가 스크롤 애니메이션을 완료하고 스크롤 위치가 정해질 때까지 실행이 일시중지됩니다.
  3. 스크롤 후 효과 적용: await 표현식이 완료된 후 강조 표시 클래스를 전환하거나 애니메이션을 시작하거나 포커스를 이동합니다.

중단된 스크롤 및 포커스 관리

  • 빠른/중단된 스크롤 처리: 진행 중인 부드러운 스크롤이 완료되기 전에 동일한 스크롤 컨테이너에서 새 스크롤이 시작되면 브라우저는 진행 중인 부드러운 스크롤을 중단하고 해당 컨테이너의 대기 중인 모든 스크롤 프로미스를 해결 (거부하지 않음)합니다. 사용자가 빠르게 클릭할 때 대체된 스크롤의 스크롤 후 효과를 억제해야 하는 경우 증가하는 작업 ID를 추적하고 최신 스크롤이 시작된 경우 await 후에 일찍 반환합니다.
let currentScrollId = 0;
const sections = document.querySelectorAll('section');

async function scrollToAndHighlight(targetElement) {
  const scrollId = ++currentScrollId;
  sections.forEach((sec) => sec.classList.remove('highlight'));

  await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });

  // Abort follow-up effect if another scroll was triggered while awaiting
  if (scrollId !== currentScrollId) return;

  targetElement.classList.add('highlight');
}
  • 스크롤 후 타겟에 포커스 맞추기: await targetElement.scrollIntoView(...) 후 키보드 포커스를 타겟 요소 (또는 tabindex="-1"가 있는 내부 제목)로 이동할 때 { preventScroll: true }를 focus()에 전달하여 포커스 호출이 두 번째 경쟁 스크롤을 시작하지 않도록 합니다.
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });

제약 조건 및 권장사항

  • 필수: setTimeout()를 사용하여 스크롤 시간을 추측하는 대신 scrollIntoView(), scroll(), scrollTo() 또는 scrollBy()에서 직접 반환하는 Promise를 기다립니다.
  • scrollend를 기본 구현으로 수동 new Promise()에 래핑하지 마세요. 대상이 이미 표시되어 있고 스크롤 거리가 이동하지 않으면 scrollend가 실행되지 않지만, 이 경우 스크롤 메서드 프로미스는 자동으로 해결됩니다.
  • 권장사항: 스크롤 메서드를 기다린 후 .focus()를 호출할 때 { preventScroll: true }를 전달하여 브라우저가 두 번째로 이동하거나 스크롤하지 않도록 합니다.
  • 권장사항: 빠른 사용자 상호작용으로 인해 동일한 컨테이너에서 부드러운 스크롤이 겹칠 수 있는 경우 요청 토큰을 사용하여 스크롤 후 효과를 보호합니다. 부드러운 스크롤을 중단하면 이전 프로미스가 거부되는 대신 해결되기 때문입니다.
  • 루트 스크롤러와 관련된 알려진 Chrome 버그: Chrome에는 스크롤 중인 문서의 루트 스크롤러가 있을 때 scrollIntoView()에서 반환된 Promise가 너무 일찍 확인되는 버그가 있습니다. 중첩된 스크롤 컨테이너에서 scrollIntoView()를 기다리는 것이 정상적으로 작동합니다. 이 버그를 해결하기 위해 콘텐츠를 스크롤 컨테이너(예: overflow-y: auto가 있는 div)로 래핑하지 마세요.

대체 전략

스크롤 메서드 프로미스를 지원하지 않는 브라우저에서 scrollIntoView(), scroll(), scrollTo(), scrollBy()는 Promise 대신 undefined를 반환합니다.

  • 점진적 개선 (기본 await 동작): await undefined는 다음 마이크로태스크에서 즉시 확인되는 유효한 JavaScript이므로 await element.scrollIntoView({ behavior: 'smooth' })를 작성해도 지원되지 않는 브라우저에서 오류가 발생하지 않습니다. 대신 스크롤이 시작되는 즉시 스크롤 후 코드를 실행하여 단계적으로 성능을 저하시킵니다. 일시적인 강조 표시와 같이 중요하지 않은 시각적 장식의 경우 이 방법으로 충분한 경우가 많습니다.
  • 안정적인 대체 (scrollend + 제한 시간): Baseline 타겟이 scroll-promises를 지원하지 않고 이전 브라우저에서 스크롤이 완료될 때까지 스크롤 후 작업을 기다려야 하는 경우 반환 값이 Promise (result instanceof Promise)인지 확인합니다. undefined인 경우 요소가 이미 표시되어 있고 스크롤이 발생하지 않는 경우에 대비해 짧은 대체 제한 시간으로 스크롤 컨테이너에서 scrollend 이벤트를 기다립니다.
function scrollIntoViewAsync(element, options, scroller = window) {
  const result = element.scrollIntoView(options);

  // Feature detect: in supporting browsers, scrollIntoView() returns a Promise
  if (result instanceof Promise) {
    return result;
  }

  // Fallback for browsers where scrollIntoView() returns undefined
  return new Promise((resolve) => {
    let timeoutId;

    const finish = () => {
      clearTimeout(timeoutId);
      scroller.removeEventListener('scrollend', finish);
      scroller.removeEventListener('scroll', onScroll);
      resolve();
    };

    const onScroll = () => {
      // Restart the idle timer on every scroll event. If scrollend never fires
      // (browsers without scrollend support), this resolves once scrolling goes idle.
      clearTimeout(timeoutId);
      timeoutId = setTimeout(finish, 100);
    };

    scroller.addEventListener('scrollend', finish, { once: true });
    scroller.addEventListener('scroll', onScroll, { passive: true });

    // If no scroll event fires within the idle window (e.g., already in view), resolve.
    // 100ms is an example value; it only needs to exceed the gap between scroll events.
    timeoutId = setTimeout(finish, 100);
  });
}