Прокрутка и scrollIntoView

геометрия≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Прокрутка — свойство не страницы, а любого блока, которому тесно внутри своих границ. Браузер даёт три уровня доступа: прочитать позицию числом, задать её самому и попросить scrollIntoView посчитать всё за тебя.

Разделы темы:·

У каждого прокручиваемого элемента есть scrollTop — сколько пикселей содержимого уехало вверх за верхнюю границу. Ноль — самый верх, максимум примерно равен scrollHeight - clientHeight: это те самые размеры из статьи «Размеры элемента: offset, client, scroll», где остаток до низа уже считали. Свойство не только читается, но и записывается: присвоил число — блок мгновенно прыгнул. Больше максимума присвоить нельзя, браузер молча обрежет.

Для страницы целиком ту же роль играет window.scrollY (и window.scrollX по горизонтали). Это ровно то же число, что document.documentElement.scrollTop, только доступное лишь на чтение. Старое имя pageYOffset — полный синоним, его ещё встретишь в чужом коде.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Присваивать scrollTop руками приходится редко: есть scrollTo — прокрутить к указанной позиции, и scrollBy — сдвинуть относительно текущей. Оба метода есть и у элементов, и у window: window.scrollTo(0, 0) — классическая кнопка «наверх».

У каждого две формы вызова. Короткая берёт два числа в порядке x, y — порядок легко перепутать, потому что читаем мы обычно «сверху вниз». Объектная форма { top, left, behavior } длиннее, зато самоописательна, а left в ней можно не указывать.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

У объектной формы есть behavior: "instant" — мгновенно, "smooth" — с анимацией, "auto" (по умолчанию) — «как решит CSS». Именно поэтому свойство scroll-behavior: smooth на контейнере делает плавным даже обычное присваивание scrollTop: оно тоже считается прокруткой в режиме auto. А явный "instant" сильнее CSS всегда.

Главная ловушка плавности — асинхронность. Сразу после вызова с "smooth" в scrollTop лежит ещё старое значение: анимация идёт по кадрам. Если нужно что-то сделать по прибытии, слушай событие scrollend, а не читай позицию следующей строкой.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Плавность нравится не всем. У части людей в системе включено prefers-reduced-motion, и длинная анимация прокрутки для них не красота, а укачивание. Проверяется одной строкой: window.matchMedia("(prefers-reduced-motion: reduce)").matches — и behavior становится instant.

Для прокруток в режиме auto ту же просьбу учитывают и без скрипта: scroll-behavior: smooth кладут внутрь @media (prefers-reduced-motion: no-preference) { … }, и плавность достаётся только тем, кто меньше движения не просил. Сам медиазапрос и то, зачем людям эта настройка, разобраны в прологе — меньше движения по просьбе человека.

Чаще всего задача звучит не «прокрути на 340 пикселей», а «покажи вот этот элемент». Для неё есть element.scrollIntoView() — вся арифметика с координатами остаётся браузеру. Важная деталь: метод прокручивает все прокручиваемые предки, а не только ближайший, поэтому элемент действительно окажется на виду.

Настраивают его два ключа — block (вертикаль) и inline (горизонталь), плюс знакомый behavior. По умолчанию { block: "start", inline: "nearest" }. Самое вежливое значение — "nearest": если элемент и так виден, прокрутки не будет вовсе.

ЗначениеКуда встанет элемент
startк началу видимой области (сверху / слева)
centerпо центру видимой области
endк концу видимой области (снизу / справа)
nearestминимальный сдвиг; если виден — не двигаемся
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Классическая беда: block: "start" ставит элемент вплотную к верхней границе, а там висит липкий заголовок. Чинить это смещением в JavaScript не надо — есть CSS-свойство scroll-margin-top на самом элементе. Оно расширяет его «зону прицеливания», и браузер оставит запас сам.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Когда открыт диалог, фон прокручиваться не должен. Базовый приём — overflow: hidden на контейнере, для страницы это document.body.style.overflow = "hidden". Элемент при этом не перестаёт быть окном прокрутки: позиция сохраняется, программный scrollTo продолжает работать — запрещён только жест пользователя.

Две ловушки. Первая: у страницы исчезает полоса прокрутки, видимая ширина растёт, и весь макет дёргается вправо — лечится компенсирующим padding-right. Вторая: прокрутка внутри самой модалки, докрутившись до края, «протекает» на фон — лечится overscroll-behavior: contain на её содержимом.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ширину полосы прокрутки считают как разницу между window.innerWidth (вместе с полосой) и document.documentElement.clientWidth (без неё). Нулю она равна там, где полоса накладывается поверх содержимого — например на телефонах, и тогда компенсация просто ничего не сдвинет.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Событие scroll вешается на прокручиваемый элемент, а для страницы — на window или document. У него два характерных свойства. Во-первых, оно неотменяемое: preventDefault внутри обработчика ничего не остановит, прокрутка уже случилась. Во-вторых, оно приходит не мгновенно после изменения scrollTop, а перед отрисовкой ближайшего кадра.

Отсюда правило: в обработчике только запоминаем позицию, а всё тяжёлое — чтение размеров, классы, анимации — делаем внутри requestAnimationFrame, иначе страница начнёт заикаться. А если вопрос звучит как «виден ли элемент» — бесконечная лента, ленивая загрузка, подсветка пункта меню — бери IntersectionObserver: он решает ту же задачу вообще без обработчика прокрутки.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

  • scrollTop у элемента и window.scrollY у страницы — одна и та же величина; браузер сам обрезает её до диапазона от нуля до scrollHeight - clientHeight.
  • scrollTo задаёт позицию, scrollBy сдвигает от текущей; обе понимают объект { top, left, behavior }.
  • behavior: "smooth" анимирует прокрутку: сразу после вызова позиция ещё старая, конец ловится событием scrollend; "instant" перебивает CSS-свойство scroll-behavior.
  • scrollIntoView избавляет от арифметики: block и inline решают, куда встанет элемент, а scroll-margin оставляет запас под липкую шапку.
  • Замок прокрутки — это overflow: hidden плюс компенсация ширины полосы; событие scroll приходит перед кадром и не отменяется, а «виден ли элемент» лучше спрашивать у IntersectionObserver.

1/3Что выведет box.scrollTo({ top: 300, behavior: "smooth" }); console.log(box.scrollTop);, если блок был в самом верху?

2/3Элементу, у которого максимум прокрутки 400, присвоили el.scrollTop = 999999. Что покажет чтение el.scrollTop?

3/3Открывая модалку, ставим document.body.style.overflow = "hidden". Что чаще всего ломается визуально?

Долистать ленту до низа

Есть лента сообщений в блоке с прокруткой. Напиши atBottom(el) — отвечает true, когда элемент долистан до самого низа (допуск в один пиксель, потому что позиция бывает дробной), и toBottom(el) — мгновенно доводит его донизу.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Максимум прокрутки — это scrollHeight - clientHeight. Значит, «сколько осталось до низа» считается как scrollHeight - scrollTop - clientHeight. Сравнивать с нулём строго нельзя: при дробном масштабе экрана остаток бывает вроде 0.4, поэтому берём допуск.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В toBottom можно передать любое заведомо большое число — браузер обрежет его до максимума. Передавать el.scrollHeight удобнее: и заведомо больше нужного, и читается как намерение.

Плавно, но уважительно

Напиши pickBehavior(), который возвращает "instant", если в системе включена настройка «меньше движения», и "smooth" в остальных случаях. Функция goTo уже готова и должна заработать без изменений.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Системную настройку читают тем же медиазапросом, что и в CSS, только из JavaScript: window.matchMedia возвращает объект со свойством matches.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тот же объект умеет сообщать об изменении настройки на лету через addEventListener("change", handler). Но для разовой прокрутки проще спрашивать matches прямо в момент вызова — так значение всегда свежее.