Індикатор прогресу прокрутки для статей: CSS‑перший підхід, мінімум JS, що не зламає ваш UX
Ви випустили гарний довгий матеріал. Після цього аналітика показує, що читачі «відскакують» через 12 секунд. Можливо, їм нудно. А можливо, вони загубилися. Крихітний індикатор прогресу не врятує погане письмо, але він зупинить сприйняття добре написаного тексту як нескінченного коридору без виходів.
Підступ у тому, що більшість індикаторів побудовані як cron‑скрипт молодого SRE — працюють у щасливому шляху, тануть під навантаженням і тихо брешуть щодо крайових випадків. Зробімо такий, що поводиться правильно в реальних браузерах, на реальних телефонах і з реальними бюджетами продуктивності.
Що ви насправді будуєте (і чому це ламається)
Індикатор прогресу прокрутки звучить як декоративний елемент інтерфейсу. Насправді це живий телеметричний показник, що живиться з одного з найчастіших «сигналів» у браузері: прокрутки. Якщо ви підключите його неправильно, отримаєте не просто трохи неточний бар. Ви отримаєте:
- Джанк: пропущені кадри, бо ви примушуєте layout на кожному тіку прокрутки.
- Витрачання батареї: мобільні пристрої виконують зайву роботу заради декорації.
- Неправильний прогрес: бо ви виміряли не те (висота документа ≠ висота статті).
- Зміщення макета: бо ваш бар змінює layout замість того, щоб просто малюватися.
- Регресії доступності: бо ви створили стан, який скрінрідери не можуть інтерпретувати, або приховали елементи керування у верхній частині сторінки.
Хитрість — розділити відповідальності як у продакшн‑системі:
- Вимірювання — єдина частина, яка потребує JavaScript.
- Відтворення — це робота CSS: фіксований/липкий елемент і трансформ/ширина на основі однієї змінної.
- Планування — тут народжуються баги: вам потрібне щонайбільше одне оновлення за кадр, а не 80 на секунду через ентузіазм до scroll-подій.
Правило: поводьтеся з прогресом прокрутки як з каналом метрик. Частота вибірки, точність і вартість мають значення. Не «оновлюйте DOM просто так». Саме так з’являється інтерфейс, що виглядає як би він буферизується.
Факти та трохи історії (бо веб любить повторювати помилки)
Контекст допомагає приймати кращі рішення. Ось конкретні моменти, які пояснюють, чому «простий» скрол UI так часто йде не так:
- Події прокрутки раніше спрацьовували з дуже різною частотою у різних браузерах; багато реалізацій фактично були «best effort» і були прив’язані до здоров’я головного потоку.
- Мобільні браузери ввели асинхронну прокрутку (прокрутку на потоці композитора), щоб відчуття було плавним навіть коли JavaScript зайнятий; це зробило прості ефекти на основі прокрутки менш надійними.
- Ранні віджети «reading progress» часто використовували jQuery плюс
$(window).scroll(), що заохочувало читання/запис layout у тому ж обробнику. Працювало — поки контент не став довгим. position: stickyдесятиліттями по‑різному вели себе в браузерах; до того багато сайтів симулювали липкі заголовки JS‑ом, що подвоювало роботу при прокрутці.- CLS (Cumulative Layout Shift) став офіційною метрикою у еру Web Vitals, змусивши команди звертати увагу на дрібні зміни макета — наприклад, прогрес‑бар, що штовхає контент вниз.
- IntersectionObserver з’явився, щоб зменшити потребу в опитуванні при виявленні видимості. Це не панацея для прогрес‑барів, але корисний інструмент для деяких «лише стаття» вимірювань.
- Кастомні CSS‑властивості зробили практичним прокачування одного числового значення з JS у кілька CSS‑ефектів без торкання структури DOM.
- Динамічні одиниці viewport у Safari та поведінка адресного рядка неодноразово дивували команди; вимірювання діапазонів прокрутки на основі висоти viewport складніше, ніж здається.
І так, ми все ще робимо UI на основі прокрутки в 2025 році. Різниця в тому, чи робите ви це по‑дорослому.
Вимоги, які важливі в продакшені
Якщо ви будуєте це для реального видання, внутрішнього вікі, порталу документації або маркетингового сайту з довгими технічними статтями, визначте вимоги наперед. Інакше ваш «крихітний бар» перетвориться на ферму тикетів надійності.
Функціональні вимоги
- Вимірює прогрес через контент статті, а не через весь документ (навігація, футер, коментарі, релевантні посилання — це шум).
- Обробляє динамічний контент: пізнє завантаження зображень, розгортання вбудованих елементів, перемикання блоків коду, заміни шрифтів.
- Працює в вкладених контейнерах прокрутки, якщо ваша верстка їх використовує (поширено в «docs app» оболонках).
- Не блокує введення: верх сторінки часто містить контролі, breadcrumbs, кнопки назад. Ваш бар не повинен перехоплювати кліки.
Нефункціональні вимоги
- Мінімальна робота головного потоку: ідеально — одне обчислення за кадр під час прокрутки та нічого у просте.
- Жодного трешу з layout: уникайте шаблонів, які змушують синхронний layout (читати layout після запису, знову й знову).
- Стабільний макет: бар має накладатися, а не робити релоут (якщо лише дизайн явно не резервує місце).
- Граціозне резервне відтворення: якщо JS не працює, сторінка все одно читається — бар може просто бути порожнім.
- Семантика доступності: не створюйте «ARIA‑спам», але не приховуйте істотний стан, якщо ви подаєте його як інформацію.
«Ідея парафразом»: Ви будуєте — ви експлуатуєте; володіння означає піклування про оперованість, а не тільки про випуск фічі.Werner Vogels (парафраз)
Архітектура CSS‑перший: нехай CSS робить нудну роботу
CSS добре в двох речах, що тут важливі: позиціювання й малювання. Дозвольте йому робити обидва. Ваш елемент індикатора має бути дурним: контейнер, закріплений зверху, і дочірній елемент, що візуально заповнюється на основі одного числового значення.
Виберіть правильну модель позиціювання
У вас фактично дві розумні опції:
position: stickyна обгортці, яка знаходиться у верхівці документа. Добре, коли область хедера бере участь у макеті й ви хочете, щоб бар зник, якщо хедер прокручується.position: fixedдля бару, що завжди видно. Добре, коли ви хочете, щоб він був нездатний до особливостей overflow предків і вам не важлива контекстна участь у макеті.
Я за замовчуванням обираю sticky, коли бар — частина chrome статті і ви резервуєте її висоту. Я обираю fixed, коли сайт має складні app‑shell контейнери, трансформи або правила overflow, які роблять sticky непередбачуваним.
Не анімуйте layout, якщо можна анімувати paint
Ви побачите реалізації, що роблять width: X%. Це не автоматично погано, але може викликати більше роботи layout, ніж потрібно, залежно від оточення. Більш безпечний підхід — відрендерити повноширинний бар і масштабувати його:
- Задайте елементу заповнення
width: 100%. - Використовуйте
transform: scaleX(var(--progress))зtransform-origin: left.
Трансформи зазвичай дружні до композитора. Зазвичай. Все одно треба виміряти і перевірити.
Думка Використовуйте transform: scaleX(), якщо у вас немає дизайнерської потреби інакше. Так важче випадково викликати layout і легше оптимізувати.
Не дозволяйте бару відбирати кліки
Якщо бар накладається зверху, він може перехоплювати кліки по кнопках хедера. Додайте:
pointer-events: noneна progress shell, якщо вам не потрібна інтерактивність.
Це типовий баг, який потрапляє в реліз, бо під час QA ніхто не натискає кнопку «назад» у верхньому лівому куті. Користувачі натискають — завжди.
Приклад CSS (тільки відтворення)
Ми вже включили sticky‑бар на цій сторінці. Важлива частина — контракт: CSS читає кастомну властивість з назвою --progress в діапазоні [0, 1]. Все інше — стилізація.
Мінімальний JS: одна задача, одна змінна, без драми
Роль JavaScript — обчислити прогрес і встановити --progress. І тільки це. Ніякого churn у DOM. Ніякого innerHTML. Немає десятка querySelector на кожному тіку прокрутки.
Що має означати «прогрес»
Є три поширені визначення. Оберіть свідомо:
- Прогрес документа: наскільки ви просунулися по всій сторінці. Легко, але вводить в оману, коли є великий футер або блок коментарів.
- Прогрес статті по верху/низу: 0% коли верх статті досягає верху viewport; 100% коли низ статті досягає низу viewport (або верху). Це відповідає відчуттю «я закінчив читати».
- Прогрес статті по лінії зору: на основі маркера (наприклад, поточний заголовок). Складніше, але корисно в документації з навігацією.
У цій статті ми фокусуємося на #2, бо це чесно і стабільно.
Планування: requestAnimationFrame, а не сирі scroll‑виклики
Події прокрутки можуть спрацьовувати часто і нерегулярно. Якщо ви обчислюєте і встановлюєте CSS на кожній події, ризикуєте виконати зайву роботу і перемішати читання/запис некоректно.
Шаблон, що поводиться правильно:
- Слухайте scroll (passive).
- При прокрутці заплануйте один
requestAnimationFrame, якщо ще не заплановано. - У rAF прочитайте необхідне, обчисліть прогрес, запишіть одну CSS‑змінну.
- Також оновлюйте при зміні розміру і при зміні контенту, що впливає на layout.
Мінімальна JS‑реалізація
Вставте це в кінець тіла (або як відкладений скрипт). Припускається, що ваш контейнер статті — це <main id="content"> або конкретніший <article>, який ви оберете.
cr0x@server:~$ cat progress.js
(() => {
const root = document.documentElement;
const target = document.querySelector("main#content") || document.body;
let ticking = false;
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function compute() {
ticking = false;
const rect = target.getBoundingClientRect();
const viewport = window.innerHeight || root.clientHeight;
// Progress definition:
// 0 when target top is at top of viewport
// 1 when target bottom is at bottom of viewport
const total = rect.height - viewport;
let p;
if (total <= 0) {
// Content shorter than viewport: "done"
p = 1;
} else {
p = (-rect.top) / total;
}
root.style.setProperty("--progress", clamp01(p).toFixed(4));
}
function requestTick() {
if (!ticking) {
ticking = true;
requestAnimationFrame(compute);
}
}
// Passive scroll listener: don't block scrolling
window.addEventListener("scroll", requestTick, { passive: true });
window.addEventListener("resize", requestTick);
// Update once on load
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", compute, { once: true });
} else {
compute();
}
// Watch for layout changes that affect height (images, embeds, font swaps)
if ("ResizeObserver" in window) {
const ro = new ResizeObserver(requestTick);
ro.observe(target);
}
})();
Ось «мінімальний JS» для бару. Це не нульовий JS. Це правильна кількість JS: один цикл вимірювання, один запис у CSS‑змінну, один кадр анімації. Все інше має бути обґрунтованим.
Жарт №1: Якщо вашому прогрес‑бару потрібен станний автомат — ви не відстежуєте прогрес читання, ви будуєте космічну програму.
Чому це обчислення працює
getBoundingClientRect() дає верх елемента відносно viewport. Коли ви прокручуєте вниз, rect.top стає від’ємним. Знаменник rect.height - viewport — це загальна відстань прокрутки, потрібна елементу, щоб перейти від «вирівняний по верху» до «вирівняний по низу».
Крайові випадки, які враховано:
- Короткий контент: якщо стаття поміщається у viewport, прогрес = 1. Ви можете обрати 0, якщо вважаєте, що «нема прокрутки = нема прогресу», але це зазвичай виглядає зламаним.
- Перепрокрутка / bounce: обмежено в
[0,1], щоб iOS‑еластичне прокручування не показувало негативний прогрес. - Динамічна висота контенту: ResizeObserver тригерить перерахунок при зміні висоти статті.
Варіанти: прокрутка документа, контейнера та «реальна стаття»
Варіант A: прогрес усього документа (легко, часто неправильно)
Якщо ваша сторінка фактично — це стаття і невеличкий футер, прогрес по документу прийнятний. Обчислення просте: scrollTop поділити на (scrollHeight – clientHeight). Також він найчастіше бреше, коли ви додаєте панель «Релевантні статті» розміром з новелу.
cr0x@server:~$ cat document-progress.js
(() => {
const root = document.documentElement;
let ticking = false;
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function compute() {
ticking = false;
const scrollTop = root.scrollTop || document.body.scrollTop;
const max = root.scrollHeight - root.clientHeight;
const p = max > 0 ? scrollTop / max : 1;
root.style.setProperty("--progress", clamp01(p).toFixed(4));
}
function requestTick() {
if (!ticking) {
ticking = true;
requestAnimationFrame(compute);
}
}
window.addEventListener("scroll", requestTick, { passive: true });
window.addEventListener("resize", requestTick);
compute();
})();
Користуйтеся ним, коли ваш лейаут простий і стабільний. Інакше вимірюйте статтю, а не всесвіт.
Варіант B: прогрес контейнера прокрутки (оболонки docs app)
Багато корпоративних docs‑сайтів поміщають панель читання у контейнер прокрутки, поки сайдбар залишається фіксованим. Події window scroll не рухаються. Ваш бар має слухати контейнер і вимірювати його scrollTop.
Головний підводний камінь: position: sticky і position: fixed поводяться інакше всередині overflow‑контейнерів. Якщо ваш app‑shell використовує overflow: hidden на body і прокручується div, віддайте перевагу бару, приєднаному до скрол‑контейнера, а не до window.
cr0x@server:~$ cat container-progress.js
(() => {
const root = document.documentElement;
const scroller = document.querySelector("[data-scroll-container]");
if (!scroller) return;
let ticking = false;
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function compute() {
ticking = false;
const max = scroller.scrollHeight - scroller.clientHeight;
const p = max > 0 ? scroller.scrollTop / max : 1;
root.style.setProperty("--progress", clamp01(p).toFixed(4));
}
function requestTick() {
if (!ticking) {
ticking = true;
requestAnimationFrame(compute);
}
}
scroller.addEventListener("scroll", requestTick, { passive: true });
window.addEventListener("resize", requestTick);
compute();
})();
Варіант C: «реальна стаття» з початковими/кінцевими офсетами
Іноді ви хочете, щоб прогрес починався після hero‑зображення або закінчувався до підписки на розсилку. Ви можете додати «сентінели» — один на початку і один в кінці — і обчислювати прогрес на основі їхніх позицій. Це більш надійно, ніж вгадувати офсети чарівними числами.
Сентінели також прості в управлінні: їх можна інспектувати та переміщувати без переписування математики.
Доступність і UX: прогрес — це інформація
Індикатор прогресу — це візуальна підказка. Для деяких читачів це також інструмент для ухвалення рішення: «Чи встигну я це дочитати?». Якщо ви ставитеся до нього як до чисто декоративного елемента, добре — тоді тримайте його aria-hidden і не робіть з нього керування.
Коли показувати його допоміжним засобам
Якщо бар — лише тонка лінія вгорі, виставляти його як live‑progressbar зазвичай — шум. Скрінрідерам не потрібен постійний потік «32%, 33%, 34%» під час прокрутки. Це як колега, що описує вашу поїздку.
Кращі опції:
- Тільки декоративно: тримайте
aria-hidden="true"на елементі прогресу, як у прикладі. - Показувати за запитом: надайте текстову мітку «Прогрес читання: 42%» в тулбарі, яка оновлюється рідко (наприклад, коли прокрутка зупиняється) або лише при фокусі.
- Використовувати як частину навігації: якщо ви також даєте контролі «перейти до розділу», тоді прогрес стає справжнім UI‑компонентом із семантикою.
Колір, контраст і «не бути креативним»
Прогрес‑бар — не веселка. Ваша головна задача — читабельність на світлому й темному тлі та несуперечність з хедером. Використовуйте тонкий фон треку і виразний колір заповнення. Тестуйте з режимами примусового контрасту, якщо ви їх підтримуєте.
Поважайте reduced motion
Зазвичай прогрес‑бар не створює проблем з рухом, але деякі дизайни додають easing або відскоки. Не варто. Індикатор прокрутки має відстежувати прокрутку. Якщо ви додаєте затримку, ви створюєте недовіру. Користувачі ненавидять брехливі лічильники; спитайте будь‑кого, хто бачив завислий спінер.
Модель продуктивності: чому обробники прокрутки джанкять
Прокрутка здається плавною, коли браузер встигає виробити кадри вчасно (приблизно 60fps на багатьох пристроях, 120fps на новіших). Оновлення вашого індикатора конкурує з усім іншим: перерахунком стилів, layout, paint, скриптами, декодуванням зображень і тим, що вирішили робити сторонні аналітики сьогодні.
Два великі режими відмови продуктивності
1) Примушений синхронний layout
Якщо ваш обробник читає layout (наприклад, getBoundingClientRect) після запису чогось, що інвалідовує layout (наприклад, зміна ширини або класів), браузеру доводиться негайно скинути layout. Це може відбуватися на кожному тіку прокрутки. Вітаю, ви винайшли генератор джанку.
2) Забагато оновлень
Навіть якщо кожне оновлення «швидке», виконувати його 200 разів на секунду все одно може бути повільно. requestAnimationFrame обмежує оновлення до одного за кадр і дає браузеру планувати роботу розумно.
Чому підхід CSS‑перший допомагає
Тримати відтворення в CSS і записувати одну кастомну властивість зменшує мутації DOM і інвалідизацію стилів. Це також робить код аудиторським: коли майбутній колега додасть ще один querySelectorAll у луп прокрутки, очевидно, що він збирається нашкодити.
А як щодо «scroll-driven animations» у CSS?
Сучасний CSS має примітиви для анімацій, прив’язаних до прокрутки, у деяких браузерах. Вони багатообіцяючі, особливо тому, що можуть перенести роботу з головного потоку. Але міжплатформна поведінка й продуктові обмеження все ще роблять мінімальний JS більш переносимим сьогодні.
Якщо ви можете надійно використовувати pure CSS scroll‑linked animations у наборі браузерів, які підтримуєте, робіть це. Інакше ставте це як IPv6: правильно, неминуче і все ще повне підводних каменів, коли ви найменше цього хочете.
Практичні завдання: 12+ перевірок з командами, результатами та рішеннями
Ви хочете, щоб індикатор поводився. Це означає, що ви тестуєте як оператор, а не як демонстратор. Нижче реальні завдання з командами, прикладами виводів, що вони означають, і рішеннями.
Завдання 1: Перевірте, чи HTML не резервує несподіваний простір у макеті
cr0x@server:~$ rg -n "progress-shell|bar-height|position: fixed|position: sticky" -S ./dist
dist/app.css:42:.progress-shell { position: sticky; top: 0; height: var(--bar-height); }
dist/index.html:12:<div class="progress-shell" aria-hidden="true">
Що означає вивід: Ви підтверджуєте, як розташований бар і чи бере він участь у макеті. Sticky з явною висотою означає, що ви зарезервували місце (немає CLS через вставку). Fixed зазвичай накладається (немає впливу на макет), але може перекривати UI хедера.
Рішення: Якщо бачите fixed без урахування хедера, додайте top padding або встановіть pointer-events: none, щоб уникнути блокування взаємодій.
Завдання 2: Перевірте, що CSS‑змінна налаштована під час виконання
cr0x@server:~$ node -e "console.log('Check in DevTools: document.documentElement.style.getPropertyValue(\"--progress\")')"
Check in DevTools: document.documentElement.style.getPropertyValue("--progress")
Що означає вивід: Це нагадування, що перевірити: змінна має бути встановлена на documentElement (або відомому scope) і бути числовою.
Рішення: Якщо вона порожня або «NaN», ваш JS не запустився, селектор не знайшов елемент або обчислення поділило на нуль.
Завдання 3: Підтвердіть, що скрипт завантажено з defer або в кінці body
cr0x@server:~$ rg -n "<script.*progress(\.js)?|defer" ./dist/index.html
35:<script src="/assets/progress.js" defer></script>
Що означає вивід: Використання defer не блокує парсинг і гарантує, що DOM існує на момент виконання.
Рішення: Якщо не використано defer і скрипт у head — перемістіть або додайте defer. Scroll UI не має затримувати перший рендер.
Завдання 4: Перевірте на випадкові слухачі scroll, додані фреймворками або плагінами
cr0x@server:~$ rg -n "addEventListener\\(\"scroll\"|onscroll|wheel\\)|IntersectionObserver" ./dist -S
dist/assets/progress.js:18:window.addEventListener("scroll", requestTick, { passive: true });
dist/assets/vendor.js:9912:window.addEventListener("scroll", onScroll);
Що означає вивід: Є інший слухач scroll у vendor‑коді. Це може бути нормально або бути реальним джерелом джанку.
Рішення: Проведіть аудит додаткового обробника. Якщо він читає layout або пише стилі на кожну подію — виправте або тро́лінгуйте. Не звинувачуйте прогрес‑бар за чиюсь іншу помилку.
Завдання 5: Переконайтеся, що слухачі scroll пасивні
cr0x@server:~$ rg -n "addEventListener\\(\"scroll\".*passive" ./dist/assets/progress.js
22:window.addEventListener("scroll", requestTick, { passive: true });
Що означає вивід: Пасивні слухачі scroll повідомляють браузеру, що ви не будете викликати preventDefault(), тому прокрутка може залишатися плавною.
Рішення: Якщо passive відсутній — додайте його, якщо тільки вам дійсно не потрібно відміняти прокрутку (для прогрес‑бару це не потрібно).
Завдання 6: Виявити зміщення макета через пізню вставку бару
cr0x@server:~$ rg -n "document\\.createElement\\(|insertBefore\\(|prepend\\(" ./src -S
src/progress-init.js:4:document.body.prepend(shell);
Що означає вивід: Ви динамічно інжектите бар. Це часто викликає CLS, бо змінює макет після першого візуального рендеру.
Рішення: Віддавайте перевагу сервер‑рендерингу розмітки бару або резервуйте місце за допомогою CSS перед інжекцією. Якщо потрібно інжектити — вставте заповнювач тієї ж висоти раніше.
Завдання 7: Переконайтеся, що ви вимірюєте правильний елемент (стаття vs сторінка)
cr0x@server:~$ rg -n "querySelector\\(\"(article|main|\\#content|\\.post)\"\\)" ./src/progress.js
3: const target = document.querySelector("main#content") || document.body;
Що означає вивід: Це прив’язує вимір до main#content. Якщо ваш сайт обгортає навігацію + футер в main, прогрес стане оманливим.
Рішення: Змініть селектор на реальний контейнер статті (article, [data-article]) або додайте стартові/кінцеві сентінели.
Завдання 8: Переконайтеся, що прогрес‑бар не перекриває інтерактивні елементи хедера
cr0x@server:~$ rg -n "pointer-events" ./dist/app.css
58:.progress-shell { position: sticky; top: 0; z-index: 999; height: var(--bar-height); background: var(--bar-bg); box-shadow: var(--shadow); }
Що означає вивід: pointer-events: none не знайдено.
Рішення: Додайте pointer-events: none до shell, якщо він не інтерактивний. Це уникне тикетів «чому не працює логотип».
Завдання 9: Підтвердіть, що ваша CSS‑анімація/трансція не бреше
cr0x@server:~$ rg -n "transition:|animation:" ./dist/app.css
Що означає вивід: Немає transitionів. Добре: бар відстежує реальну позицію без затримки.
Рішення: Якщо знайдете easing на width/transform — прибирайте або обмежуйте. Індикатори прогресу мають бути точними, не кінематографічними.
Завдання 10: Знайдіть довгі задачі головного потоку під час прокрутки (швидка локальна перевірка)
cr0x@server:~$ node -e "console.log('Use Chrome DevTools Performance: record a scroll, then look for Long Task markers > 50ms in Main.')"
Use Chrome DevTools Performance: record a scroll, then look for Long Task markers > 50ms in Main.
Що означає вивід: Це підказка оператора: записати, прокрутити і переглянути. Якщо головний потік заблокований, ваш бар не зможе оновлюватися плавно.
Рішення: Якщо під час прокрутки бачите довгі задачі, знайдіть, чи вони від вашого обробника, рендерингу шрифтів, підсвічування синтаксису або сторонніх скриптів. Виправляйте найбільший блокер спочатку.
Завдання 11: Перевірте, чи зображення або вбудовані елементи змінюють висоту статті після завантаження
cr0x@server:~$ rg -n "<img |loading=|width=|height=" ./dist/index.html
88:<img src="/assets/hero.webp" loading="lazy">
Що означає вивід: Зображення lazy‑завантажується, але може не мати width/height атрибутів. Це спричиняє зміщення макета при завантаженні.
Рішення: Додайте width/height (або CSS aspect-ratio), щоб макет був стабільний. Ваше обчислення прогресу залежить від висоти елемента; нестабільна висота дає стрибки прогресу.
Завдання 12: Переконайтеся в підтримці ResizeObserver або виберіть запасний варіант
cr0x@server:~$ node -e "console.log('If you support older browsers, gate ResizeObserver and also recompute on load + font load events.')"
If you support older browsers, gate ResizeObserver and also recompute on load + font load events.
Що означає вивід: ResizeObserver широко підтриманий, але якщо у вас сувора політика підтримки старих браузерів — потрібна стратегія fallback.
Рішення: Без ResizeObserver оновлюйтеся на load, resize і, можливо, після завантаження шрифтів (або прийміть невеликі неточності).
Завдання 13: Переконайтеся, що ваш бар не викликає зайвих repaint
cr0x@server:~$ node -e "console.log('In DevTools Rendering, enable Paint flashing and scroll. The bar should repaint cheaply, not trigger full-page flashes.')"
In DevTools Rendering, enable Paint flashing and scroll. The bar should repaint cheaply, not trigger full-page flashes.
Що означає вивід: Paint flashing покаже, чи ваші оновлення інвалідовують великі області.
Рішення: Якщо весь хедер перерисовується кожен кадр — спростіть ефекти (прибрати важкі box-shadow/фільтри) або перемістіть бар на свій шар (обережно; шари теж коштують пам’яті).
Завдання 14: Перевірте, що реалізації container‑scroll вимірюють правильний корінь прокрутки
cr0x@server:~$ rg -n "scrollTop|scrollHeight|clientHeight|data-scroll-container" ./src -S
src/container-progress.js:12: const max = scroller.scrollHeight - scroller.clientHeight;
Що означає вивід: Ви використовуєте метрики контейнера, а не вікна. Добре для app‑shells.
Рішення: Якщо прогрес ніколи не змінюється, ваш контейнер може не бути коренем прокрутки. Знайдіть фактичний елемент прокрутки і прикріпіть обробник до нього.
Швидкий плейбук діагностики
Коли хтось каже «бар повільний» або «стрибки», не сперечайтеся про естетику. Проведіть швидку триаж‑перевірку, як при спалаху латентності.
По-перше: підтвердьте коректність вимірювань
- Чи вимірюється правильний елемент? Перевірте селектор і bounding rect. Якщо вимірюється body, а читання відбувається в вкладеному контейнері — числа марні.
- Чи стабільний діапазон прокрутки? Якщо зображення/вбудовані елементи завантажуються пізно і змінюють висоту — ваш загальний діапазон змінюється під час читання.
- Чи обмежено прогрес? Еластична прокрутка та перепрокрутка можуть давати негативні або >1 значення. Якщо бар «обертається», ви забули clamp.
По‑друге: знайдіть вузьке місце
- Головний потік заблокований? Шукайте довгі задачі під час запису профілю прокрутки. Якщо так — бар невинний; він просто репортує зламану систему.
- Треш з layout? Перевірте, чи ваш обробник читає layout і записує layout в одному кадрі повторно. Мінімізуйте читання DOM, групуйте записи.
- Занадто важкий paint? Paint flashing покаже, чи бар викликає дорогі перерисовки. Градієнти зазвичай ок; фільтри і великі тіні — ні.
По‑третє: перевірте дивності конкретних браузерів
- Safari адресний рядок / динамічний viewport? Стрибки на початку/кінці можуть бути через зміну висоти viewport. Розгляньте більш стійкі вимірювання і перерахунок при resize/orientation.
- Взаємодія overflow + sticky? Якщо бар зникає або прилипає неправильно — перевірте overflow предків і трансформи.
Жарт №2: Індикатор прогресу — це маленький SLA: щойно він збреше, користувачі відкриють ментальний інцидент.
Поширені помилки: симптом → причина → виправлення
Ось суворий список. Якщо ваш бар щось дивне робить, це, швидше за все, одне з цього.
1) Бар стрибає назад під час прокрутки
Симптоми: Ви прокручуєте вниз; бар на мить зменшується або мерехтить.
Причина: Загальний діапазон прокрутки змінився під час прокрутки через завантаження зображень/вбудованих елементів, заміну шрифтів або зміну висоти колапсуючих компонентів.
Виправлення: Зарезервуйте місце для медіа (width/height або aspect-ratio). Додайте ResizeObserver і перераховуйте, використовуючи останній rect. Уникайте вимірювання контейнера, що сам переплітається через пізньо вставлені липкі хедери.
2) Бар досягає 100% перед кінцем статті
Симптоми: Ви бачите 100% ще під час читання, особливо якщо футер великий або є блок «рекомендованого» контенту.
Причина: Вимірюється не той елемент — прогрес документа замість прогресу статті.
Виправлення: Вимірюйте спеціальний контейнер статті або використовуйте стартові/кінцеві сентінели, розміщені там, де «читання починається/закінчується».
3) Бар ніколи не рухається в layout docs app
Симптоми: Прогрес застряг на 0% навіть при прокрутці панелі читання.
Причина: Прокрутка відбувається в вкладеному контейнері, не у вікні. Ваш слухач на window.
Виправлення: Прикріпіть слухача до контейнера, обчислюйте прогрес за scrollTop/scrollHeight/clientHeight.
4) Верхня навігація стає неклікальною
Симптоми: Користувачі не можуть натиснути кнопки хедера біля верхнього краю; працює, якщо трохи прокрутити.
Причина: Фіксований/липкий накладний елемент перехоплює події вказівника.
Виправлення: Додайте pointer-events: none до оболонки прогрес‑бару або перемістіть його, щоб уникнути перекриття інтерактивних елементів.
5) Продуктивність прокрутки тане на мобільних
Симптоми: Оновлення прогресу відстають, прокрутка відчувається важкою, батарея сідає швидше.
Причина: Непасивні слухачі, забагато роботи в обробнику, часті примусові layout, важкі paint (фільтри, тіні).
Виправлення: Використовуйте пасивні слухачі, планування через rAF, один запис у DOM за кадр, уникайте дорогих візуальних ефектів і видаліть зайві слухачі прокрутки, що читають layout.
6) Бар неточний коли адресний рядок колапсує/розгортається (mobile Safari)
Симптоми: Прогрес змінюється без прокрутки або стрибає біля верху.
Причина: Висота viewport динамічно змінюється; ваш знаменник залежить від висоти viewport.
Виправлення: Перераховуйте на resize (вже). Якщо стрибки лишаються — використайте контейнер з фіксованою висотою або обробляйте малі дельти viewport як шум (дебаунс оновлень resize).
7) CLS регресія коли з’являється бар
Симптоми: Контент зсувається вниз після завантаження сторінки.
Причина: Елемент бару вставлено після початкового рендеру без зарезервованого простору.
Виправлення: Рендерте бар у початковому HTML, резервуйте його висоту або накладайте його за допомогою fixed, щоб він не впливав на макет.
Три корпоративні історії з країни «в мене на MacBook працювало»
Міні‑історія 1: Інцидент через хибне припущення
Вони мали платформу контенту з довгими статтями і блискучий редизайн. Хтось додав «reading progress» прив’язаний до document.documentElement.scrollTop і сказав, що все готово. У QA бар виглядав чудово. Тестові сторінки були короткі з підставним футером.
Потім настала продакшн. Реальні сторінки мали систему коментарів, що підвантажувалася після основного контенту, плюс «рекомендовані» картки, що розгорталися при включеному A/B‑тесті. Користувачі досягали 100% прогресу, ще будучи посередині статті, бо знаменник (висота прокрутки) змінився після того, як обчислення вже зробило припущення про стабільний загальний розмір.
Тикети в саппорт були безцінні: «Ваш сайт каже, що я закінчив читати, але я ще не закінчив». Люди рідко скаржаться на тонку блакитну лінію. Але тут вона підривала довіру. Це також збило з пантелику внутрішню аналітику, що використовувала «досягли 100%» як проксі завершення.
Виправлення було нескладним, що зробило його ще більш принизливим. Вони перейшли на вимірювання реального контейнера статті, додали width/height до зображень і оновлювали прогрес при зміні layout через ResizeObserver. Більша зміна була культурною: припинити думати, що документ — це контент. У сучасних сайтах документ — це шухляда сміття.
Міні‑історія 2: Оптимізація, що відкотилася
Інша команда взяла курс на оптимізацію й вирішила «оптимізувати» прогрес‑бар кешуванням вимірів. Вони порахували висоту статті один раз на DOMContentLoaded, зберегли її й оновлювали прогрес, використовуючи цю константу. Вони навіть прибрали дорогий getBoundingClientRect() з шляху прокрутки. На швидкому ноутбуці бенчмарк виглядав краще. Всім здавалося, що вони розумні.
Потім підвантажився шрифт. Текст перейшов, змінюючи висоту рядків, і висота статті виросла. На деяких сторінках з блоками коду синтаксичне підсвічування теж спрацювало після першого рендеру і змінило макет. Прогрес‑бар почав «плисти». На 80% він показував 95%. Наприкінці він ніколи не досягав 100%. На мобільних було ще гірше, бо висота viewport змінювалася при колапсі URL‑бару, а кешований знаменник цього не враховував.
Вони «виправили» це, перераховуючи кожні 250ms по таймеру, що швидко перетворилося на батарейний метроном. Наступний спринт пішов на відкат оптимізації і заміну її на ResizeObserver + rAF update loop — саме той підхід, який вони раніше відкидали як «занадто багато». Урок: кешування — не оптимізація, якщо базове значення змінюється. Це не кешування, це брехня з упевненістю.
Міні‑історія 3: Нудна, але правильна практика, що врятувала день
Одна команда документації зробила нудну справу: записала критерії прийняття для прогрес‑бару. Він мав відстежувати панель читання (не вікно), працювати з вбудованими діаграмами, не заважати контролям хедера і деградуватися красиво при відсутності JS.
Вони також встановили бюджет продуктивності: робота оновлення прогресу мала втратити малу частину кадру на телефоні середнього рівня. Вони не гадали. Записали продуктивність прокрутки в DevTools на репрезентативних сторінках і поклали трасси в папку регресій. Коли хтось змінив CSS хедера і додав важкий blur, час малювання під час прокрутки різко виріс і бар почав «стрибати». Трасса зробила винуватця очевидним.
Оскільки бар був CSS‑перший і JS‑мінімальний, виправлення було переважно дизайнерським: прибрати blur, спростити тіні і ізолювати шар прогресу. Бар залишився однією властивістю CSS. Без переписувань, без нічних патчів.
Нудні практики — вимірювання, бюджети, репрезентативні тест‑сторінки — тримають маленькі фічі від перетворення на довготривалу боргову операцію.
Чеклисти / покроковий план
Якщо хочете план, який можна виконати без тижня зустрічей — ось він.
Чеклист A: Побудувати компонент (CSS‑перший)
- Додайте елемент progress shell у верх документа (або всередині scroll‑контейнера).
- Стайліть його з
position: stickyабоfixed. Оберіть свідомо. - Зробіть fill дочірнім елементом, що масштабується на основі
--progress. - Встановіть
pointer-events: none, якщо не потрібно інтерактивності. - Переконайтеся, що висота бару зарезервована, якщо він бере участь у макеті (або накладіть його для нульового впливу).
Чеклист B: Підключити мінімальний JS‑цикл оновлення
- Оберіть ціль вимірювання: елемент статті або скрол‑контейнер.
- Реалізуйте оновлення через rAF; не оновлюйте прямо на кожну подію scroll.
- Обмежте прогрес до
[0, 1]. - Записуйте одну CSS‑змінну на
documentElement(або відомий scope). - Оновлюйте при
resizeі при зміні розміру контенту (ResizeObserver, якщо доступний).
Чеклист C: Перевірити поведінку на «реальному контенті»
- Протестуйте коротку статтю (вміщується у viewport): прогрес має читатися як завершений або поводитися відповідно до обраного правила.
- Протестуйте статтю з багатьма зображеннями і вбудованими елементами: прогрес не має стрибати назад після завантаження ресурсів.
- Протестуйте сторінку з великим футером або блоком релевантного контенту: прогрес має відображати статтю, а не сторінку.
- Протестуйте на mobile Safari: прокрутіть, щоб викликати колапс/розгортання адресного рядка; стежте за стрибками.
- Перевірте навігацію з клавіатури: бар не має ховати контури фокусу або блокувати контролі.
Чеклист D: Перевірки продуктивності перед релізом
- Запишіть трассування продуктивності при прокрутці і шукайте довгі задачі > 50ms.
- Увімкніть paint flashing, щоб побачити, чи бар тригерить великі перерисовки.
- Переконайтеся, що є лише один цикл оновлення під час прокрутки (або що кілька виправдані).
- Підтвердіть пасивні слухачі подій.
- Переконайтеся у відсутності CLS через пізню інжекцію.
Питання та відповіді
1) Чи можна зробити індикатор прогресу прокрутки без жодного JavaScript?
Інколи, в деяких браузерах, можна використати scroll‑driven CSS анімації. Якщо вам потрібна широка підтримка і передбачувана поведінка — мінімальний JS залишається прагматичним вибором. Нуль JS — гарний заголовок, але не завжди стабільна система.
2) Краще використовувати width чи transform: scaleX()?
За замовчуванням — transform: scaleX(). Він зазвичай уникає роботи layout і плавніший. Використовуйте width, якщо дизайн вимагає цього і ви перевірили, що це не викликає дорогий reflow у вашому лейауті.
3) Чому не оновлювати бар прямо в обробнику scroll?
Бо події scroll можуть спрацьовувати частіше, ніж дозволяє ваш бюджет кадру. rAF дозволяє оновлювати максимум раз на кадр і тримає читання/запис згрупованими. Це різниця між вибіркою сигналу і реакцією на кожен електрон.
4) Як зробити, щоб прогрес відстежував лише статтю, а не коментарі й релевантні посилання?
Вимірюйте спеціальний контейнер статті або помістіть явні стартові/кінцеві сентінели навколо контенту, який вважаєте «читанням». Уникайте «висоти документа», якщо на сторінках є змінні додатки.
5) Який підхід для SPA з вкладеними панелями прокрутки?
Прикріпіть слухача до фактичного скрол‑контейнера і обчислюйте прогрес за його scrollTop і scrollHeight. Window scroll часто не працює в SPA, що блокують прокрутку тіла.
6) Чи ResizeObserver викликає проблеми з продуктивністю?
Може, якщо ви спостерігаєте занадто багато або виконуєте важку роботу в колбеку. Тут ви спостерігаєте один елемент і просто плануєте rAF‑оновлення. Це дешево і доречно. Уникайте спостереження великих піддерев для цієї фічі.
7) Чому бар мерехтить на сторінках з колапсуючими блоками коду?
Бо висота контенту змінюється під час прокрутки. Переконайтеся, що прогрес перераховується після розгортання (ResizeObserver допомагає). Також перевірте, що компонент коду не виконує дорогі reflow під час прокрутки.
8) Чи має бар показувати 0% на початку або маленьке ненульове значення?
Показуйте 0% до моменту фактичного початку статті. Якщо в лейауті є hero вище статті, визначте початок точно (сентінел) або користувачі побачать «прогрес» до початку читання, що відчувається як спідометр, що падає на стоянці.
9) Як уникнути покриття тіні липкого хедера баром?
Прийміть рішення щодо шарування: або бар — частина chrome хедера (всередині нього), або накладається над ним. Не дозволяйте z‑index вій відбуватися випадково. Призначте чіткий контекст стекінгу.
10) Чи потрібно дебаунсити resize‑події?
Зазвичай ні, якщо робота при resize легка і планується через rAF. Якщо бачите «шторм» resize (зміна орієнтації, UI viewport), можна додати простий дебаунс, але почніть з вимірювання. Припущення породжують баги.
Висновок: наступні кроки, які можна відправити в реліз
Будьте з індикатором прогресу як із надійною системою: звужуйте відповідальності, вимірюйте реальну річ і не створюйте зайвої роботи.
- Визначте, що означає «прогрес» для вашого продукту (документ vs стаття vs діапазон, визначений сентінелами).
- Відрендерте бар у CSS, використовуючи одну кастомну властивість
--progress. - Оновлюйте цю властивість мінімальним JS: пасивний слухач scroll + rAF + clamp + ResizeObserver.
- Тестуйте на сторінках з реальним контентом (пізнє завантаження зображень, вбудовані елементи, довгі блоки коду).
- Прогоніть швидкий плейбук діагностики перед релізом, а потім зберігайте трасу продуктивності для регресійного аналізу.
Якщо зробите все так, індикатор стане тим, чим і має бути: тихим, чесним індикатором. Не джерелом нових інцидентів. Ваш on‑call графік заслуговує хоча б на це.