Как создать индикатор прогресса чтения
Создайте индикатор прогресса чтения на JavaScript или CSS scroll-driven animations: точная формула прокрутки, производительность и доступность.
Индикатор прогресса чтения — это тонкая фиксированная полоска (обычно закреплённая у верхней границы viewport), которая заполняется от 0% до 100% по мере прокрутки длинной статьи.
Первая версия, которую я выпустил, доходила до 100% примерно за три экрана до конца поста, потому что она незаметно учитывала комментарии и футер наравне со статьёй. Как оказалось, правильная обработка именно этой детали и составляет основную часть работы.
Реализовать индикатор можно двумя способами: через JavaScript-обработчик события scroll, который задаёт ширину полоски на основе вычисленного процента прокрутки, либо через чисто CSS-анимацию, управляемую скроллом, вообще без JavaScript. В этом руководстве разобраны оба подхода, корректная математика прокрутки для индикаторов уровня документа и уровня статьи, детали производительности, которые сохраняют обработчик скролла дешёвым, а также вопросы доступности и прогрессивного улучшения, которые нужно решить перед выпуском.
Ключевые выводы
- Для индикатора по всему документу прогресс прокрутки равен
scrollTop / (scrollHeight − clientHeight) × 100; для индикатора, отслеживающего только статью, измеряйте<article>:window.scrollY / ((article.clientHeight + article.offsetTop) − window.innerHeight) × 100. - Используйте формулу уровня статьи, когда на странице есть блоки похожих постов, комментарии или высокий футер — тогда индикатор достигнет 100% в конце поста, а не внизу страницы.
- Поскольку событие
scrollсрабатывает почти на каждом кадре, выполняйте обновление ширины внутриrequestAnimationFrameи кэшируйте чтение размеров, пересчитывая их только приresize, чтобы обработчик никогда не вызывал синхронный layout. - CSS-версия не требует JavaScript: задайте фиксированной полоске
animation-timeline: scroll(),@keyframesс анимациейtransformотscaleX(0)доscaleX(1)иanimation-duration: 1ms— именно это нужно Firefox, чтобы он вообще применил анимацию (под флагом или в Nightly). - Анимации, управляемые скроллом, доступны в Chrome/Edge 115+, Safari 26+ и Opera, но пока не входят в Baseline, поскольку стабильный Firefox по-прежнему скрывает их за флагом. Рассматривайте CSS-версию индикатора как прогрессивное улучшение.
Что такое индикатор прогресса чтения и когда его использовать?
Индикатор прогресса чтения визуально кодирует «сколько от этого поста осталось» в виде полоски, растущей вдоль верхней части экрана. Он подходит для длинных материалов (подробных туториалов, эссе, документации), где читателю полезно ощущение своего положения, которого современные тонкие скроллбары уже не дают. На коротких страницах, лендингах или чём-либо, что укладывается в один-два экрана, он добавляет визуальный шум, никого при этом не информируя, — там его лучше не использовать.
Два решения на этапе проектирования определяют всю остальную реализацию: какую область измеряет индикатор (весь документ или только тело статьи) и реализуете ли вы его на JavaScript или на CSS.
Как вычислить прогресс чтения?
Discover how at OpenReplay.com.
Правильно посчитайте математику — и всё остальное сложится само. Есть две корректные формулы, в зависимости от того, что должен отражать индикатор.
Прокрутка всего документа. Для индикатора, заполняющегося по мере прокрутки всей страницы, прогресс равен пройденному расстоянию, делённому на максимальное прокручиваемое расстояние:
progress = scrollTop / (scrollHeight − clientHeight) × 100
В знаменателе вычитается видимая высота, поскольку последний «экран» контента невозможно прокрутить за пределы видимости: низ страницы достигается тогда, когда целый экран ещё виден. Для корневого скроллера scrollHeight — это общая высота контента, а clientHeight — видимая высота.
Прокрутка в границах статьи. Индикатор уровня документа учитывает футер, комментарии и блоки похожих постов, поэтому он достигает 100% внизу страницы, а не в конце поста. Чтобы это исправить, измеряйте вместо этого элемент <article>:
distance = (article.clientHeight + article.offsetTop) − window.innerHeight
progress = window.scrollY / distance × 100
Здесь distance — это траектория прокрутки от первой отрисовки до момента, когда нижняя граница статьи попадает в область видимости. Используйте формулу уровня статьи, когда под постом есть что-то существенное; используйте формулу уровня документа, когда прокручиваемый контент и есть вся страница. Учтите, что offsetTop измеряется относительно ближайшего позиционированного родителя, поэтому держите статью в нормальном потоке документа, чтобы это значение означало «расстояние от верха страницы».
Реализация на JavaScript
JavaScript-подход работает во всех браузерах и является единственным способом получить точный прогресс в границах статьи. Вам понадобится фиксированный элемент-полоска, немного CSS и обработчик скролла.
<div id="progress-bar" aria-hidden="true"></div>
#progress-bar {
position: fixed;
top: 0;
left: 0;
width: 0;
height: 4px;
background: linear-gradient(to right, #7b2ff7, #f107a3);
z-index: 9999;
}
const bar = document.getElementById("progress-bar");
const article = document.querySelector("article");
let distance = 0;
let ticking = false;
function measure() {
distance = (article.clientHeight + article.offsetTop) - window.innerHeight;
}
function update() {
const progress = Math.min((window.scrollY / distance) * 100, 100);
bar.style.width = `${progress}%`;
ticking = false;
}
function onScroll() {
if (!ticking) {
requestAnimationFrame(update);
ticking = true;
}
}
window.addEventListener("load", () => { measure(); update(); });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", measure);
Измерения выполняются в обработчике load, чтобы изображения и шрифты уже загрузились, и значение clientHeight было точным. Замените distance, вычисляемый по формуле уровня статьи, на формулу уровня документа, если вам нужен индикатор для всей страницы.
Как сохранить обработчик скролла быстрым
Событие scroll может срабатывать почти на каждом кадре анимации, поэтому наивный обработчик, читающий layout и записывающий стили на каждое событие, — надёжный источник подёргиваний. Дешёвым его сохраняют два правила.
Во-первых, группируйте визуальную запись внутри requestAnimationFrame с помощью флага ticking, как показано выше, чтобы обновлять полоску не чаще одного раза за кадр, независимо от частоты события scroll. Во-вторых, кэшируйте чтение размеров. Чтение clientHeight/offsetTop на каждом событии скролла заставляет браузер сбрасывать отложенный layout, и эти повторяющиеся reflow на практике и есть layout thrashing, поэтому вычисляйте distance один раз и пересчитывайте его только при resize. Именно так чаще всего и выглядит сбой в продакшене: обработчик без троттлинга, который читает геометрию и записывает width на каждое событие, — а session replay страниц с интенсивной прокруткой регулярно выявляет вызванные этим пропуски кадров. Регистрация обработчика с { passive: true } также сообщает браузеру, что вы не будете вызывать preventDefault, благодаря чему прокрутка остаётся плавной.
Индикатор прогресса чтения только на CSS
Полоску можно построить вообще без JavaScript, используя CSS-анимации, управляемые скроллом. Привяжите анимацию к таймлайну прокрутки вместо истёкшего времени, и браузер сам будет управлять горизонтальным масштабом полоски исходя из позиции скролла. Поскольку анимация нацелена на transform, а не на свойство, влияющее на layout, она может выполняться на композиторе, а не проходить через обработчик скролла в основном потоке.
<div id="reading-progress" aria-hidden="true"></div>
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
#reading-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
z-index: 9999;
background: #7b2ff7;
transform: scaleX(0);
transform-origin: left;
animation-name: grow-progress;
animation-timeline: scroll();
animation-duration: 1ms; /* required so the animation runs in Firefox */
animation-timing-function: linear;
}
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
@media (prefers-color-scheme: dark) {
#reading-progress { background: #fc0; }
}
}
}
Полоска раскладывается на всю ширину и сжимается до нуля с помощью transform: scaleX(0), а затем масштабируется обратно по мере прокрутки. Именно transform-origin: left заставляет её расти от левого края, а не от центра. Анимация width выглядела бы идентично, но вызывала бы пересчёт layout на каждом кадре, что возвращает анимацию в основной поток.
Важны ещё две детали. При вызове без аргументов scroll() выбирает ближайшего прокручиваемого родителя и следует его блочной оси, что для большинства одноколоночных макетов статей означает корневой скроллер; передайте root, если хотите указать его явно. А Firefox отказывается применять анимацию, если animation-duration не отличен от нуля, поэтому привычное значение 1ms — это то, что заставляет её работать там, и то же значение сохраняет полоску скрытой в браузерах без поддержки.
Последний пункт — это как раз компромисс. animation-timeline не входит в Baseline. Он доступен в Chrome и Edge 115+, Safari 26+ и Opera, тогда как стабильный Firefox по-прежнему держит его за флагом layout.css.scroll-driven-animations.enabled и включает по умолчанию только в Nightly. Приведённая выше защита через @supports — это контракт прогрессивного улучшения: поддерживающие браузеры получают CSS-полоску, остальные — ничего, поэтому дополните её JavaScript-версией как fallback, если вам нужно универсальное покрытие. Учтите также, что CSS-версия измеряет весь контейнер прокрутки, поэтому она учитывает футер и комментарии точно так же, как JS-формула уровня документа.
JavaScript против чистого CSS: что выбрать
| JavaScript-индикатор | Индикатор только на CSS | |
|---|---|---|
| Поддержка браузерами | Везде | Chromium 115+, Safari 26+; Firefox за флагом |
| Точность в границах статьи | Да | Нет, учитывает всю страницу |
| Нагрузка на основной поток | Обработчик скролла | Нулевая на кадр, transform выполняется на композиторе |
| Требуется JavaScript | Да | Нет |
Используйте JavaScript, когда индикатор должен останавливаться в конце поста или нужна поддержка всех браузеров; используйте CSS-версию, когда вам нужен индикатор для всей страницы с минимумом кода и его можно рассматривать как улучшение.
Доступность и финальные штрихи
Индикатор прогресса — декоративный элемент интерфейса, поэтому помечайте его aria-hidden="true", чтобы убрать его из дерева доступности, из вывода скринридеров и из порядка фокуса. Если вам действительно нужно, чтобы значение озвучивалось, используйте вместо этого role="progressbar" с динамически обновляемым aria-valuenow, хотя для большинства индикаторов чтения корректно именно скрытие. Оберните CSS-анимацию в @media (prefers-reduced-motion: no-preference), чтобы пользователи, отказавшиеся от анимаций, не получали движущийся элемент, и подберите цвет полоски с достаточным контрастом относительно вашего хедера, чтобы она оставалась заметной и в светлой, и в тёмной теме.
Оба подхода дают одинаковый визуальный результат; JavaScript-версия даёт точность в границах статьи и универсальную поддержку, а CSS-версия — более компактную реализацию, которая держит покадровую работу вне основного потока. Начните с того варианта, который соответствует вашим целевым браузерам, сохраните математику прокрутки и деталь с animation-duration: 1ms в точности как показано, и объедините оба подхода через @supports, если хотите получить лучшее из обоих миров.
Часто задаваемые вопросы
Почему мой индикатор прогресса доходит до 100 процентов раньше, чем я дочитываю статью?
Индикатор измеряет весь документ, а не статью, поэтому в прокручиваемое расстояние попадают футер, комментарии и блоки похожих постов. Перейдите на формулу уровня статьи: вычислите distance как (article.clientHeight + article.offsetTop) минус window.innerHeight, а затем разделите window.scrollY на это расстояние. Тогда индикатор достигнет 100 процентов в конце поста, а не внизу страницы.
Почему CSS-индикатор прогресса работает в Chrome, но не в Firefox?
В стабильных релизах Firefox анимации, управляемые скроллом, скрыты за флагом layout.css.scroll-driven-animations.enabled, и настройка включена по умолчанию только в Nightly, поэтому Firefox без включённого флага не отрисует ничего. Кроме того, Firefox вообще не применит анимацию, если animation-duration не отличен от нуля, — именно поэтому все используют значение 1ms. Для полного покрытия дополните CSS-индикатор проверкой at-supports и JavaScript-фолбэком.
Работает ли CSS-индикатор без обработчика события scroll?
Да. CSS-анимации, управляемые скроллом, привязывают анимацию к таймлайну прокрутки, а не к истёкшему времени, поэтому браузер управляет transform полоски напрямую от позиции скролла — без JavaScript-обработчика скролла и без IntersectionObserver в основном потоке. Именно анимация transform, а не width, делает её дружественной к композитору: в Chromium и Safari 26.4 и новее анимация выполняется в потоке композитора, тогда как в более ранних версиях Safari 26.x анимации, управляемые скроллом, выполнялись в основном потоке. Анимация width или height вызывала бы пересчёт layout на каждом кадре и возвращала бы эту работу в основной поток во всех браузерах.
Должен ли индикатор прогресса чтения быть доступен скринридерам?
Нет, для большинства индикаторов чтения — не должен. Индикатор прогресса — декоративный элемент интерфейса, поэтому помечайте его aria-hidden='true', чтобы убрать его из дерева доступности, из вывода скринридеров и из порядка фокуса. Только если вам действительно нужно озвучивание значения, стоит использовать role='progressbar' с динамически обновляемым атрибутом aria-valuenow, но для чисто визуального индикатора чтения корректным поведением по умолчанию является его скрытие.