В вашей дизайн-системе могут быть особые требования к внешнему виду скроллбаров.

Возможно, вы хотите, чтобы они исчезали, когда курсор находится за пределами контейнера. Или вам нужны свои цвета, форма и эффекты при наведении. К сожалению, нативные возможности стилизации скроллбаров весьма ограничены, поэтому, если вы сталкивались с подобными требованиями, единственный вариант — делать собственный компонент.

Для этого нам нужно решить несколько задач:

  1. Понять, когда нужно показывать скроллбары.

  2. Понять, какого размера должен быть ползунок.

  3. Понять, где его расположить.

  4. Научиться скроллить контейнер перетаскиванием ползунка.

  5. Ползунок должен реагировать на прокрутку, произошедшую любым другим способом.

Четвёртый пункт решается довольно тривиальным drag-and-drop. А для всего остального обычно приходится использовать комбинацию ResizeObserver и обработчика события scroll, немного считать всё это дело в JavaScript и получать нужные значения.

Но у такого подхода есть свои проблемы. Чтение offsetHeight/scrollHeight может вызывать reflow, события scroll прилетают много раз в секунду, а постоянное ручное обновление значений в DOM — довольно дорогая операция, необходимая, чтобы ползунок всегда находился в правильном месте и имел правильный размер.

Но что, если я скажу вам, что в 2026 году JavaScript для всего этого нам больше вообще не нужен? Давайте разберёмся, как scroll-driven animations, уже доступные в Chrome, Safari и Firefox Nightly, могут сделать всю работу за нас.

Размещаем скроллбары

Прежде чем переходить непосредственно к задаче, нам нужно понять, как расположить ползунки внутри нашего скролл-контейнера. Мы хотим зафиксировать их относительно рамки контейнера, но если использовать абсолютное позиционирование, они уедут вместе с остальным содержимым при прокрутке.

Раньше я уже писал про эту проблему и про то, как position: sticky в сочетании с хитрыми хаками через margin и float позволяет добиться нужного результата. Обязательно загляните в эту статью, если любите поломать голову над CSS-паззлами:

CSS задачи на 200 IQ
У вас бывает такое чувство, когда вы при выполнении работы упёрлись в какую-то проблему и что-то вну...
habr.com

Но здесь мы говорим о передовом CSS образца 2026 года, поэтому можем воспользоваться современным решением — anchor positioning. В общих чертах наш компонент будет выглядеть так:

<scrollbar>
  …
  <bars>
    <bar />
    <bar />
  </bars>
</scrollbar>

Очевидно, что у скроллбара две оси, но для простоты мы сосредоточимся только на вертикальной. Всё, о чём я буду говорить дальше, точно так же применимо и к горизонтальной.

Чтобы расположить наши ползунки, зададим якорь на скроллбаре и воспользуемся им в элементе с ползунками:

.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);
}

Теперь контейнер с ползунками идеально лежит поверх скролл-контейнера независимо от прокрутки, а внутри него мы уже можем использовать обычное абсолютное позиционирование для самих ползунков.

Также мы добавим anchor-scope, потому что не хотим, чтобы наши ползунки привязались к какому-нибудь другому скроллбару на странице.

Настраиваем анимации

Наши анимации будут зависеть от прогресса прокрутки. Для начала спрячем нативные скроллбары, определим timeline-scope и scroll-timeline — по умолчанию он работает по вертикальной оси — а заодно зададим начальные значения переменных:

.scrollbar { 
  scrollbar-width: none;
  scroll-timeline: --scrollbar;
  timeline-scope: --scrollbar;
  
  --opacity: 0;
  --events: none; 
}

Чтобы задавать положение ползунка через CSS-переменные, сперва нужно зарегистрировать их как числа, чтобы браузер умел их анимировать. После этого добавим простые анимации, отслеживающие прогресс:

@property --start {
  syntax: "<number>";
  inherits: true;
  initial-value: 0; 
}

@property --end {
  syntax: "<number>";
  inherits: true;
  initial-value: 0; 
}

@keyframes top {
  from { --start: 1 }
  to   { --start: 0 }
}

@keyframes bottom {
  from { --end: 0 }
  to   { --end: 1 }
}

Нам также понадобится ещё одна анимация, которая будет показывать скроллбар, когда содержимое действительно переполняет контейнер. Значения в from и to здесь будут одинаковыми, потому что сама анимация запустится только в том случае, если контента достаточно для прокрутки:

@keyframes bar { 
  from, to { 
    --opacity: 1;
    --events: auto;
  }
}

Применяем стили

Теперь подключим все эти анимации с линейной функцией времени:

.bar {
  animation-name: top, bottom, bar;
  animation-timing-function: linear;
  animation-timeline: --scrollbar;
}

Изменяя CSS-переменные в зависимости от положения прокрутки, мы можем двигать ползунок сверху вниз. Но нам также нужно, чтобы его размер отражал отношение видимой части контейнера ко всей прокручиваемой области.

Это означает, что верхний край ползунка начинает движение с 0, но заканчивает его, не доходя до 100%. Добиться такого эффекта можно с помощью animation-range.

Если мы «притворимся», что прокрутить можно ещё на один offsetHeight дальше, то в момент, когда контейнер действительно будет прокручен до самого конца, прогресс нашей анимации остановится ровно там, где нужно.

Но как объяснить animation-range, что его нужно увеличить именно на эту величину? Тут нам поможет ещё одна классная возможность CSS — Container Queries!

Благодаря anchor positioning контейнер с ползунками имеет те же размеры, что и скролл-контейнер, поэтому мы можем объявить его size-контейнером:

.bars {
  container-type: size;
}

Теперь его дочерние элементы имеют доступ к его размерам, а значит, мы можем задать 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;
}

Остаётся применить полученные значения к самому ползунку.

Здесь нужно помнить ещё об одном граничном случае — очень длинных страницах. Мы не хотим, чтобы ползунок ужался настолько, что пользователь перестанет его видеть, не говоря уже о возможности за него ухватиться.

Комбинация min-block-size и CSS-функции 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);
}

Добавляем взаимодействие

Поскольку наш компонент — это по сути обычный скролл-контейнер со спрятанным нативным скроллбаром, большая часть пользовательского взаимодействия работает из коробки.

Когда появляется возможность прокрутки, контейнер можно сфокусировать с клавиатуры. Стрелки и Page Up/Page Down работают как обычно. Колесо мыши и тачпад — тоже. На мобильных устройствах нативный touch-scroll никуда не делся.

Единственное, что нам осталось реализовать самостоятельно, — перетаскивание ползунка указателем. И вот здесь JavaScript всё-таки понадобится, но реализация довольно прямолинейная.

На событии pointerdown нам нужно сохранить несколько вещей: pageX/pageY и текущее состояние скроллбара, чтобы знать начальные значения.

Есть один нюанс, о котором нельзя забывать: в языках с направлением письма справа налево scrollLeft становится отрицательным, поэтому нам также понадобится соответствующий множитель:

bar.addEventListener('pointerdown', ({ pageX, pageY }) => {
  const rtl = scrollbar.matches('[dir="rtl"] :scope') ? -1 : 1;
  const { scrollTop, scrollLeft, offsetHeight, offsetWidth, scrollHeight, scrollWidth } = 
    scrollbar;

  // …

Дальше добавим обработчик pointermove, который будет вызывать scrollTo с behavior: 'instant'. Это важно, чтобы перебить возможный CSS scroll-behavior, заданный где-нибудь выше по дереву:

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' });
};

Остаётся только подписаться на события и не забыть снять обработчики, когда перетаскивание закончится:

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);

Заключение

Вот и всё! Благодаря нескольким строчкам простого JavaScript с нашим скроллбаром теперь можно полноценно взаимодействовать, а всю остальную работу берёт на себя CSS.

Полное демо можно посмотреть в CodePen:

Зачем вообще может понадобиться такой подход на практике? Несколько причин:

  • Единообразный внешний вид скроллбаров в дизайн-системе независимо от браузера и операционной системы.

  • Желание всегда рисовать скроллбары поверх контента. После удаления overflow: overlay добиться этого с нативными скроллбарами на Windows невозможно. Или наоборот: если внутри контейнера есть чётко очерченные границы и вы не хотите, чтобы на macOS скроллбар на них наезжал.

  • Уменьшение визуального шума, когда на странице одновременно находится много прокручиваемых контейнеров.

  • Какой-нибудь навороченный кастомный дизайн с вау-эффектом, который придумали ваши дизайнеры, а реализовывать теперь, конечно же, вам.

Поэтому кастомные скроллбары встречаются по всему интернету: от гигантов вроде Facebook, Discord и Spotify до WYSIWYG-редакторов вроде VS Code в браузере или CodePen.

Ниже можно посмотреть ещё несколько демо с примерами:

В свежей версии Taiga UI, библиотеки компонентов под Angular, которую я разрабатываю уже много лет, компонент скроллбаров теперь работает по описанной в этой статье схеме, если браузер пользователя поддерживает scroll-driven animations. Это помогло нам избавиться от лишних requestAnimationFrame и тонны reflow/repaint, сохранив единый внешний вид и поведение на различных платформах, как и требовалось по дизайну.

Комментарии (2)


  1. gtwenty
    08.09.2026 09:55

    Терпеть ненавижу кастомные скроллбары.


  1. Soniferous
    08.09.2026 09:55

    Кто в 2026 пишет 2к26)))