Іноді дизайн-система вимагає, щоб скролбари виглядали по-особливому: зникали, коли курсор не над контейнером, мали нестандартні кольори, форми чи ефекти при наведенні. Проблема в тому, що нативні можливості стилізації скролбарів дуже обмежені, тож єдиний вихід у таких випадках — писати власний компонент.
Щоб реалізувати такий компонент, доводиться розв'язати одразу кілька задач.
- Визначити, коли показувати скролбар
- Розрахувати розмір повзунка
- Визначити позицію повзунка
- Дати змогу перетягувати повзунок мишкою для прокрутки контейнера
- Синхронізувати повзунок зі скролом, що відбувається іншими способами
Четверте завдання зазвичай вирішують доволі простою drag-and-drop взаємодією. Решту традиційно реалізують комбінацією ResizeObserver та обробника події скролу, які виконують арифметику на JavaScript. Але це може провокувати reflow при читанні offsetHeight/scrollHeight: подія скролу спрацьовує по кілька разів на секунду, і ручне оновлення DOM-значень для точного позиціонування й розміру повзунка — доволі витратна операція.
Автор матеріалу стверджує: у 2026 році для всього цього JavaScript уже не потрібен. У статті пропонується розібратися, як scroll-driven animations — доступні в Chrome, Safari та Firefox Nightly — можуть узяти всю цю роботу на себе.
Застереження щодо доступності
Автор одразу зауважує, що не є експертом із доступності, і, як із будь-якою кастомізацією, можливі нюанси, які він міг проґавити. Проте оскільки описана техніка не втручається в нативну механіку скролу, а лише змінює вигляд повзунка, для більшості користувачів вона має бути безпечною.
Розміщення скролбарів
Перш ніж перейти до основного завдання, треба вирішити, як позиціонувати повзунки всередині контейнера прокрутки. Вони мають бути прикріплені до рамки контейнера, але абсолютне позиціонування призведе до того, що вони прокручуватимуться разом з рештою вмісту. Раніше автор уже писав про подібну ситуацію та про те, як position: sticky у поєднанні з хитрими прийомами margin/floating допомагає досягти потрібного результату — цю статтю варто переглянути тим, хто любить головоломки зі стилізацією. Але тут мова йде про сучасний CSS 2026 року, тож можна покластися на новіше рішення — anchor positioning.
… Code language: HTML, XML (xml)
Хоча скролбар має дві осі, для простоти розглядається лише вертикальна — усе описане однаково застосовне і до горизонтальної. Щоб розмістити повзунки, потрібно визначити якір на скролбарі й використати його в елементі повзунків:
.scrollbar {
anchor-scope: --scrollbar;
anchor-name: --scrollbar;
}
.bars {
pointer-events: none;
position: absolute;
position-anchor: --scrollbar;
inset: anchor(top) anchor(right) anchor(bottom) anchor(left);
}
Code language: CSS (css)
Тепер контейнер повзунків ідеально позиціонований поверх контейнера прокрутки незалежно від скролу, і всередині нього можна абсолютно позиціонувати самі повзунки. Правило anchor-scope потрібне, щоб повзунки не прив'язувалися до інших скролбарів на сторінці.
Налаштування анімацій
Анімації спиратимуться на прогрес скролу. Спершу треба сховати нативні скролбари, визначити timeline-scope та scroll-timeline (за замовчуванням вісь вертикальна), а також задати початкові значення змінних:
.scrollbar {
scrollbar-width: none;
scroll-timeline: --scrollbar;
timeline-scope: --scrollbar;
--opacity: 0;
--events: none;
}
Code language: CSS (css)
Щоб задавати позицію повзунка через змінні, спочатку їх потрібно оголосити як числа, придатні для анімації. Далі задаються базові анімації для відстеження прогресу:
@property --start {
syntax: "";
inherits: true;
initial-value: 0;
}
@property --end {
syntax: "";
inherits: true;
initial-value: 0;
}
@keyframes top {
from { --start: 1 }
to { --start: 0 }
}
@keyframes bottom {
from { --end: 0 }
to { --end: 1 }
}
Code language: CSS (css)
Ще потрібна анімація, яка показує скролбар лише тоді, коли вміст перевищує розміри контейнера. Значення from і to тут однакові, оскільки анімація спрацьовує лише за наявності достатнього обсягу контенту для прокрутки.
@keyframes bar {
from, to {
--opacity: 1;
--events: auto;
}
}
Code language: CSS (css)
Застосування стилів
Далі всі ці анімації застосовуються з лінійною функцією часу:
.bar {
animation-name: top, bottom, bar;
animation-timing-function: linear;
animation-timeline: --scrollbar;
}
Code language: CSS (css)
Переходи CSS-змінних залежно від позиції скролу дозволяють пересувати повзунок згори донизу. Але потрібно також, щоб розмір повзунка відображав загальну довжину прокрутки контейнера — тобто верхній край повзунка стартує з 0, але завершується раніше, ніж досягне 100%. Цього ефекту можна досягти за допомогою animation-range: якщо «уявити», що прокрутити треба ще на один offsetHeight, прогрес анімації зупиниться саме в потрібній точці, коли контейнер прокручено до самого низу.
Питання в тому, як вказати анімації, до якого саме значення розширювати діапазон. Тут у пригоді стає ще одна цікава можливість CSS — Container Queries. Оскільки контейнер повзунків завдяки anchor positioning повторює розміри контейнера прокрутки, його можна позначити як size-контейнер:
.bars {
container-type: size;
}
Code language: CSS (css)
Тепер дочірні елементи мають доступ до розмірів контейнера, і можна задати animation-range зі зміною від 0% до 100% + 100cqb. Так само, але у зворотному напрямку — від 0% - 100cqb до 100% — рухатиметься нижній край повзунка, синхронно надаючи повзунку висоту, що відповідає співвідношенню offsetHeight до scrollHeight. Також потрібен animation-fill-mode, щоб зберігати значення на самих краях скролу.
.bar {
animation-range: 0% calc(100% + 100cqb), calc(0% - 100cqb) 100%;
animation-fill-mode: both;
}
Code language: CSS (css)
Останній крок — застосувати підсумкові стилі до повзунка. Тут варто пам'ятати про надто довгі списки прокрутки: не хочеться, щоб повзунок ставав настільки маленьким, що користувач не зможе його побачити чи натиснути. Комбінація min-block-size та функції min() дозволяє уникнути «стиснення» повзунка в екстремальних умовах.
.bar {
min-block-size: 1rem;
inset-block-start: min(calc(100% - var(--start) * 100%), calc(100% - 1rem));
inset-block-end: calc(100% - var(--end) * 100%);
opacity: var(--opacity);
pointer-events: var(--events);
}
Code language: CSS (css)
Реалізація взаємодії з користувачем
Оскільки кастомний скролбар — це просто контейнер прокрутки з прихованим нативним скролбаром, більшість взаємодій працюють без додаткового коду: контейнер стає доступним для фокусу з клавіатури, коли вміст перевищує його розміри, стрілки та Page Up/Page Down прокручують його як зазвичай, так само працюють колесо миші, тачпад і дотикова прокрутка на мобільних пристроях. Єдине, що залишається реалізувати окремо, — перетягування повзунка вказівником. JavaScript-реалізація тут доволі проста: під час події pointerdown потрібно зчитати кілька значень, а саме pageX/pageY події та поточний стан скролбару, щоб зафіксувати стартові дані. Важливо враховувати, що scrollLeft стає від'ємним у мовах з написанням справа наліво, тож потрібно передбачити відповідний множник.
bar.addEventListener('pointerdown', ({ pageX, pageY }) => {
const { scrollTop, scrollLeft, offsetHeight, offsetWidth, scrollHeight, scrollWidth } = scrollbar;
const rtl = scrollbar.matches('[dir="rtl"] :scope') ? -1 : 1;
Code language: JavaScript (javascript)
Далі потрібен обробник події pointermove, який викликає scrollTo з параметром behavior: 'instant', щоб перекрити будь-яке правило scroll-behavior вище по ієрархії DOM:
const move = e => {
const top = vertical
? scrollTop + (e.pageY - pageY) / offsetHeight * scrollHeight
: scrollTop;
const left = vertical
? scrollLeft
: scrollLeft + rtl * (e.pageX - pageX) / offsetWidth * scrollWidth;
scrollbar.scrollTo({ top, left, behavior: 'instant' });
};
Code language: JavaScript (javascript)
Насамкінець потрібно підключити ці обробники подій і подбати про те, щоб видаляти їх після завершення перетягування:
const end = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', end);
document.removeEventListener('pointercancel', end);
document.removeEventListener('contextmenu', end);
}
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', end);
document.addEventListener('pointercancel', end);
document.addEventListener('contextmenu', end);
Code language: JavaScript (javascript)
Висновок
Ось і все: тепер зі скролбаром можна взаємодіяти завдяки кільком рядкам базового JavaScript, а решту бере на себе CSS. Повну демонстрацію автор пропонує подивитися у прикладі на Pen.
Практичні причини звертатися до такого підходу можуть бути різними.
- Однакова візуальна стилістика дизайн-системи в різних браузерах і операційних системах
- Бажання зробити скролбари накладеними на всіх платформах — після видалення overflow: overlay це практично неможливо реалізувати нативними скролбарами у Windows; або навпаки — якщо в контейнері чітко визначені межі й накладання скролбарів на macOS небажане
- Зменшення візуального шуму за наявності багатьох прокручуваних контейнерів одночасно
- Втілення складних кастомних дизайнів заради ефекту 'вау', які придумали дизайнери, а реалізувати їх доведеться розробнику
Саме тому кастомні скролбари можна зустріти по всьому вебу — від таких гігантів, як Facebook, Discord і Spotify, до WYSIWYG-редакторів на кшталт веб-версії VS Code чи CodePen.
Джерело: Master.dev Blog


