Большинство туториалов по бесконечному скроллу покрывают только одно направление: вниз. Ловим конец списка, подгружаем, готово. Но в реальных приложениях нужен скролл в обе стороны: история чата, лог-вьюеры, таймлайны. А скролл вверх создаёт проблему, которой при скролле вниз просто нет.
В этом гайде я покажу, как собрать двунаправленный бесконечный скролл с нуля. Здесь React и @tanstack/react-virtual, но сама техника — просто математика над scroll offset. Работает так же в Vue, Svelte или на ванильном JS.
Проблема, наглядно
Список из 1000 элементов. Пользователь смотрит на элемент #50. Ты добавляешь 200 элементов сверху.
Что ожидаешь: пользователь по-прежнему видит элемент #50.
Что на самом деле: scroll position остаётся на том же пиксельном смещении. Но элемент #50 теперь на другом смещении (сместился вниз на высоту 200 новых элементов). Пользователь видит элемент #250. Контент прыгнул.
ДО PREPEND ПОСЛЕ PREPEND (сломано) ┌─────────────┐ ┌─────────────┐ │ item 48 │ │ item 248 ←── что? │ item 49 │ │ item 249 │ │ item 50 ◄──│── юзер │ item 250 │ │ item 51 │ видит │ item 251 │ │ item 52 │ это │ item 252 │ └─────────────┘ └─────────────┘ scrollTop: 2200px scrollTop: 2200px (тот же!) а item 50 теперь на 11000px
Виртуализация, загрузка данных, рендеринг — всё стандартно. Починить прыжок — единственная неочевидная часть.
Стек
React + TypeScript + Vite
@tanstack/react-virtual(рендерит только видимые элементы, важно для 1000+ строк)Tailwind CSS
Ещё я добавил react-chartjs-2 для bar-чарта, синхронизированного со скроллом, но это отдельно от логики скролла.
Шаг 1: хук для данных
Нужен источник данных, который умеет загружать в обе стороны. В реальном приложении это был бы API. Для демо генерирую моковые лог-события:
export function useLogData() { const [days, setDays] = useState<DayData[]>(() => generateDays(startDate, 30)); const prependCountRef = useRef(0); const loadEarlier = useCallback(() => { setDays(prev => { const newDays = generateDays(earlierDate, 15); // Запоминаем, сколько элементов добавим сверху prependCountRef.current = newDays.reduce( (sum, d) => sum + d.events.length, 0 ); return [...newDays, ...prev]; }); }, []); const loadLater = useCallback(() => { setDays(prev => [...prev, ...generateDays(laterDate, 15)]); }, []); return { days, allEvents, loadEarlier, loadLater, prependCountRef }; }
prependCountRef хранит количество только что добавленных сверху элементов. Понадобится через минуту.
Шаг 2: виртуализированный список
С @tanstack/react-virtual рендерим только ~20 видимых элементов из тысяч:
const virtualizer = useVirtualizer({ count: events.length, getScrollElement: () => parentRef.current, estimateSize: () => 44, // примерная высота строки в px overscan: 10, // доп. элементы выше/ниже viewport });
Scroll-контейнер содержит высокий пустой div (обща�� высота всех элементов), а внутри — только видимые элементы, спозиционированные через transform: translateY(). Стандартная виртуализация.
Шаг 3: загрузка в обе стороны
На каждый скролл проверяем, не подъехал ли пользователь к краю:
const handleScroll = useCallback(() => { const items = virtualizer.getVirtualItems(); if (items.length === 0) return; const firstVisible = items[0]; const lastVisible = items[items.length - 1]; // Близко к верху? Загружаем старые данные if (firstVisible.index <= 5) { loadEarlier(); } // Близко к низу? Загружаем новые данные if (lastVisible.index >= events.length - 5) { loadLater(); } }, [virtualizer]);
loadLater (дозагрузка вниз) просто работает. Virtualizer видит больше элементов, увеличивает высоту контейнера, пользователь скроллит дальше.
loadEarlier (дозагрузка вверх) ломает всё. Тут и происходит прыжок.
Шаг 4: чиним прыжок — scroll anchoring
После prepend сдвигаем scroll position вниз ровно на высоту добавленных элементов:
useEffect(() => { const prepended = prependCountRef.current; if (prepended > 0 && events.length > prevCountRef.current) { const currentOffset = virtualizer.scrollOffset ?? 0; const addedHeight = prepended * 44; // элементы × estimateSize virtualizer.scrollToOffset(currentOffset + addedHeight, { align: 'start' }); prependCountRef.current = 0; } prevCountRef.current = events.length; }, [events.length]);
ДО PREPEND ПОСЛЕ PREPEND (починено) ┌─────────────┐ ┌─────────────┐ │ item 48 │ │ item 48 │ ← то же! │ item 49 │ │ item 49 │ │ item 50 ◄──│── юзер │ item 50 ◄──│── всё ещё тут │ item 51 │ видит │ item 51 │ │ item 52 │ это │ item 52 │ └─────────────┘ └─────────────┘ scrollTop: 2200px scrollTop: 11000px (скорректирован!)
Пользователь ничего не замечает. 200 новых элементов загрузились выше viewport.
Почему ref, а не state? prependCountRef записывается внутри setDays (во время обновления state) и читается в useEffect (после обновления). Ref связывает эти два момента без лишнего ре-рендера.
Шаг 5: динамическая высота строк
Если строки раскрываются (клик по лог-записи показывает детали), virtualizer должен знать реальную высоту, а не оценочную:
export const LogItem = memo(function LogItem({ event, virtualIndex, measureRef, start }) { const [expanded, setExpanded] = useState(false); const nodeRef = useRef<HTMLDivElement | null>(null); const setRef = useCallback((node: HTMLDivElement | null) => { nodeRef.current = node; measureRef(node); // говорим virtualizer измерить этот узел }, [measureRef]); // Перемеряем ДО отрисовки при expand/collapse useLayoutEffect(() => { if (nodeRef.current) measureRef(nodeRef.current); }, [expanded]); return ( <div ref={setRef} data-index={virtualIndex} style={{ transform: `translateY(${start}px)` }}> {/* содержимое строки */} {expanded && <pre>{JSON.stringify(event.details, null, 2)}</pre>} </div> ); });
На что обратить внимание:
data-index— так@tanstack/react-virtualопределяет, какому виртуальному элементу принадлежит DOM-узел. Без негоmeasureElementне знает, какую строку измеряет.useLayoutEffect, а неuseEffect.useEffectзапускается после отрисовки — пользователь увидит один кадр, где раскрытый контент наезжает на следующую строку.useLayoutEffectзапускается до отрисовки, измерение происходит незаметно.
Результат
Скроллим вниз — подгружаются новые дни. Скроллим вверх — подгружаются старые, без прыжков. Кликаем по столбцу графика — список прокручивается к этому дню. Раскрываем лог-запись — строки ниже сдвигаются корректно.
Стартуем с ~2000 элементов, растём бесконечно в обе стороны. Virtualizer держит ~20-30 DOM-нод вне зависимости от общего числа.
TL;DR
Вся техника — две строчки:
const addedHeight = prependedCount * estimatedRowHeight; virtualizer.scrollToOffset(currentOffset + addedHeight, { align: 'start' });
Запоминаешь, сколько элементов добавил сверху. После prepend — прибавляешь их высоту к scroll offset.
Комментарии (5)

Shephard
11.03.2026 16:29Я бы за бесконечный скролл бил по рукам, а за двухсторонний бесконечный скролл загонял бы иглы под ногти тем, кто это применяет на своих веб-страницах, чтобы они страдали также как страдают пользователи.
Ты никогда не проскролишь до футера
При работе с текстом в двух достаточно удаленных разных местах страницы, ты будешь страдать. Особенно, если необходимо выделить текст в одном месте и потом прокрутить в другое. При прогрузке страницы выделение текста слетит. Ты будешь страдать и ненавидеть веб-разработчиков этой страницы.
Если в момент прокрутки глюканёт интернет, страница никогда не догрузится, а когда соединение восстановится, придется листать страницу с самого начала. Такое поведение - у 99.99% сайтов с бесконечным скроллом.
Тебе никогда не удастся сохранить то место на странице, где ты закончил читать в прошлый раз. При обновлении страницы всё теряется. С обычными страницами такого нет и не будет. Переоткроешь браузер - и ты на том же месте.
Поиск текста на странице с бесконечным скроллом - это страдание в кубе. Потому что неподгруженная часть страницы для браузера попросту не существует. Страдай снова.
[в догонку - изменённое поведение скролла] Почему какой-то кент из офиса должен решать, как будет скроллиться страница, которую я просматриваю? Этот кент меняет поведение инерции и другие параметры скроллинга, ломая все привычки пользователя и вызывая у него бешенство и ненависть к веб-разработчику.
Это только то, что сразу вспомнилось, то есть наверняка не всё.
За что вы так ненавидите пользователей?

pokrovskiy_199
11.03.2026 16:29Это, конечно, полезная статья, но это бич современных сайтов, который позволяет бездумно "висеть" на сайтах, без цели, как самураи
Vest
iPhone, Safari, iOS 26.3.1, скролл у верхней границы наверх по списку не инерционный.
AndreyMI
Это известная проблема Safari для iOS. Инерционный скролл останавливается как только вызывают scrollTo в js.
Чтобы хоть как-то порешать эту проблему, я однажды делал следующий хак:
Положил во viewport огромный контейнер высотой в миллион пикселей и перемещал контент внутри него, чтобы компенсировать подгрузку.
А чтобы нельзя было добраться до верха, выполнял центровку через scrollTo, когда переставали приходить события скролла.
Не идеальное решение, но инерция работала :)
Vest
Я не знал, что это известная проблема. Спасибо за решение, но я лучше по старинке - страницами :)