Запуск эффектов после завершения программной прокрутки

Если вы используете программную прокрутку к разделу или элементу (особенно с помощью 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.

Прерывание прокрутки и управление фокусом

  • Обработка быстрой или прерванной прокрутки. Если новая прокрутка начинается в том же контейнере до завершения текущей плавной прокрутки, браузер прерывает текущую плавную прокрутку и разрешает (не отклоняет) все ожидающие обещания прокрутки в этом контейнере. Если вам нужно подавить эффект после прокрутки для замененных прокруток, когда пользователь быстро нажимает на экран, отслеживайте идентификатор операции увеличения и возвращайтесь к началу после 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');
}
  • Фокусировка на целевом элементе после прокрутки. При перемещении фокуса клавиатуры на целевой элемент (или заголовок внутри него с помощью tabindex="-1") после await targetElement.scrollIntoView(...) передайте { preventScroll: true } в focus(), чтобы вызов фокуса не инициировал вторую, конкурирующую прокрутку:
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });

Ограничения и рекомендации

  • ОБЯЗАТЕЛЬНО. Ожидайте Promise, возвращаемый непосредственно scrollIntoView(), scroll(), scrollTo() или scrollBy(), а не угадывайте длительность прокрутки с помощью setTimeout().
  • НЕ используйте scrollend в качестве основного метода реализации new Promise(). scrollend не срабатывает, если целевой элемент уже находится в области просмотра и прокрутка не выполняется, тогда как метод прокрутки в этом случае выполняется автоматически.
  • РЕКОМЕНДУЕТСЯ передавать { preventScroll: true } при вызове .focus() после ожидания метода прокрутки, чтобы браузер не переходил и не прокручивал страницу во второй раз.
  • РЕКОМЕНДУЕТСЯ. Если быстрые действия пользователя могут вызвать наложение плавной прокрутки в одном контейнере, защитите эффекты после прокрутки с помощью токена запроса, поскольку прерывание плавной прокрутки разрешает (а не отклоняет) предыдущее обещание.
  • Известная ошибка Chrome с корневым скроллером. В Chrome есть ошибка, из-за которой Promise, возвращаемый scrollIntoView(), разрешается слишком рано, когда прокручивается корневой скроллер документа. Ожидание scrollIntoView() во вложенном контейнере прокрутки работает правильно. Не помещайте контент в контейнер с прокруткой, например div с overflow-y: auto, чтобы обойти эту ошибку.

Резервные стратегии

В браузерах, которые не поддерживают промисы методов прокрутки, scrollIntoView(), scroll(), scrollTo() и scrollBy() возвращают undefined вместо Promise.

  • Прогрессивное улучшение (поведение 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);
  });
}