Когда playbackRate почти подходит: marquee-content 5
09 Авг 2026
В mode="scroll" строка должна ускоряться вместе с прокруткой страницы. После перехода на Web Animations API скорость меняется через playbackRate. На бумаге это ровно то, что нужно: положительное значение ведёт вперёд, отрицательное разворачивает, ноль ставит на паузу.
На тестах обнаружился неприятный баг, при смене направления движения строка ощутимо дёргалась. Исправлять пришлось не формулу скорости, а способ, которым эта скорость доходит до уже запущенной анимации.
Попробую разобрать, как scroll-режим в marquee-content@5.0.3 дошёл от ручного requestAnimationFrame до element.animate() и почему обычное присваивание playbackRate пришлось заменить на updatePlaybackRate().
После GSAP
В 5.0.0 библиотека снова стала Custom Element. GSAP я убрал как лишнюю зависимость и лишний вес. Постоянное движение (mode="auto") ушло в CSS @keyframes. Скорость задаётся в px/s, а не длительностью полного цикла.
Подключение в этой версии выглядит так:
<marquee-content speed="120" mode="scroll" direction="ltr">
<span>Discounts up to 40%</span>
<span>Free returns</span>
</marquee-content>
auto браузер анимирует сам. В режиме scroll JavaScript слушает прокрутку window, оценивает скорость страницы и сдвигает дорожку. Именно этот режим пришлось доводить патчами.
Скорость страницы, а не длительность
Обработчик считает вертикальную скорость в px/s и переводит её в добавку к масштабу времени:
const velocity = deltaY / deltaTime;
this.scrollDirectionFactor = velocity >= 0 ? 1 : -1;
const extraSpeed = this.clamp(
velocity / this.options.scrollVelocityFactor,
-this.options.scrollMaxExtraSpeed,
this.options.scrollMaxExtraSpeed,
);
scrollVelocityFactor по умолчанию равен 150: прокрутка 300px в секунду даёт добавку 2. Значение ограничено диапазоном от -5 до 5. Если модуль добавки меньше 0.05, буст сбрасывается. Затухание идёт по easeOutCubic за 2500ms.
Итог — безразмерный timeScale. Он учитывает базовое направление ленты (rtl / ltr), знак прокрутки и затухающий буст. В 5.0.0 этот масштаб сразу умножался на смещение за кадр.
Сначала сгладить, потом отдать движение браузеру
Ранний scroll-цикл каждый кадр делал три вещи: считал новую позицию, писал transform, планировал следующий requestAnimationFrame.
this.offset = this.normalizeOffset(
this.offset + currentSpeed * timeScale * deltaTime,
);
this.applyTransform();
applyTransform() выставлял translate3d(...) напрямую. Масштаб времени прыгал к целевому значению за один кадр, поэтому смена направления выглядела резче, чем сама прокрутка.
Перед 5.0.2 масштаб начал догонять цель экспонентой с постоянной 0.12s:
const smoothingFactor = 1 - Math.exp(-deltaTime / 0.12);
this.currentTimeScale += (targetTimeScale - this.currentTimeScale) * smoothingFactor;
На кадре 16ms коэффициент около 0.125: лента не разворачивается мгновенно, но и не тянется секундами. Пока сглаживание не сошлось, цикл продолжается. Параллельно на дорожку повесил backface-visibility: hidden, чтобы убрать мерцание при частых перерисовках.
Сглаживание помогло, но не убрало главную цену rAF-цикла: JavaScript по-прежнему сам рассчитывал позицию и писал transform на каждом кадре. Лента не могла двигаться без очередного вызова на главном потоке.
Движение через element.animate()
В 5.0.2 постоянное смещение ушло в Web Animations API. Трек получает одну бесконечную линейную анимацию на дистанцию группы:
const animation = this.track.animate(
[
{ transform: 'translate3d(0px, 0, 0)' },
{ transform: `translate3d(${-this.distance}px, 0, 0)` },
],
{
duration: durationMs,
iterations: Number.POSITIVE_INFINITY,
easing: 'linear',
},
);
animation.currentTime = durationMs * (500 + progress);
animation.playbackRate = this.currentTimeScale;
Длительность считается из ширины группы и текущей скорости в px/s. 500 полных циклов в стартовом currentTime нужны как запас: при отрицательном playbackRate время идёт назад, и анимация не должна сразу упереться в ноль.
requestAnimationFrame после этого не двигает ленту. Он только сглаживает currentTimeScale и, пока значение не устаканилось, продолжает тикать. Когда буст погас и масштаб сошёлся с целью, цикл останавливается, а браузер продолжает анимацию без покадровых записей из JavaScript.
Это как раз то разделение, которого не хватало ручному translate3d: геометрию ведёт браузер, скрипт трогает только скорость.
Почему playbackRate почти подходит
Скорость живой анимации в 5.0.2 менялась обычным присваиванием:
if (this.scrollAnimation.playbackRate !== this.currentTimeScale) {
this.scrollAnimation.playbackRate = this.currentTimeScale;
}
На старте, пока анимация ещё создаётся, так и нужно: currentTime только что задан, синхронизировать нечего. Ошибка была в покадровом пути. Сглаживание меняет масштаб десятки раз за разворот. Каждый раз присваивание бьёт в уже идущую анимацию.
Спецификация Web Animations описывает это прямо. Установка playbackRate — синхронное обновление без попытки согласовать состояние с анимацией, которая может идти в другом потоке или процессе. В результате живая анимация может скакнуть. Чтобы скорость обновилась без скачка, спецификация указывает асинхронный updatePlaybackRate().
Смена направления как раз меняет знак масштаба. Инерция ещё не дошла до нуля, целевое значение уже отрицательное, сглаживание тащит currentTimeScale через ноль. На этом участке присваивание повторяется каждый кадр и даёт заметный рывок.
Возврат к ручному offset снова заставил бы главный поток писать transform. Дополнительное сглаживание тоже не устраняло причину: оно лишь увеличивало число записей в playbackRate.
updatePlaybackRate() сохраняет позицию
В 5.0.3 покадровый путь делает одну замену:
if (this.scrollAnimation.playbackRate !== this.currentTimeScale) {
this.scrollAnimation.updatePlaybackRate(this.currentTimeScale);
}
Метод сначала согласовывает позицию, затем выставляет скорость. playbackRate после вызова обновляется не сразу, а когда выполнится ready. Для разворота это и нужно: лента не прыгает к другому кадру цикла, меняется только темп.
Создание анимации по-прежнему начинается с присваивания. Там playbackRate задаёт начальную скорость вместе с посеянным currentTime, а не корректирует полёт.
Ручная проверка того же сценария — прокрутка вверх и вниз на телефоне и на компьютере — больше не давала рывка. Отдельного списка движков нет: дефект был везде, пока стояло присваивание.
Что осталось на JavaScript
mode="auto" после 5.0.0 JavaScript почти не трогает: пауза, prefers-reduced-motion и пересборка трека. scroll всё ещё нуждается в слушателе, оценке скорости и коротком rAF, пока экспонента не сошлась.
В этой реализации убрать rAF после перехода на Web Animations API нельзя без потери выбранного сглаживания. Без промежуточных кадров playbackRate скакал бы к цели за один шаг, даже если обновлять его правильно. Компромисс такой: браузер ведёт бесконечный сдвиг, скрипт вмешивается только пока инерция ещё жива.
Формулы скорости прокрутки и затухания буста остались от 5.0.0. Добавилось сглаживание масштаба, а затем изменился канал, по которому результат доходит до экрана.
Итоги версии 5.0.3
- После отказа от GSAP постоянное движение ушло в CSS, а scroll-режим сначала остался ручным циклом с
translate3d. - Экспоненциальное сглаживание масштаба убрало мгновенный разворот, но не избавило от покадровой записи
transform. element.animate()отдал геометрию браузеру.requestAnimationFrameоставил себе только скорость.- Присваивание
playbackRateна живой анимации почти делало нужную работу и на смене знака давало рывок. updatePlaybackRate()сохраняет текущую позицию. Для стартовой установки анимации обычное присваивание осталось уместным.