playbackRate не вывозит

08 Авг 2026

В mode="scroll" лента должна ускоряться вместе со страницей и сохранять инерцию, когда прокрутка уже остановилась. После перехода на WAAPI скорость меняется через 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 этот масштаб сразу умножался на смещение за кадр.

Сначала сгладить, потом отдать движение браузеру

Ранний цикл прокрутки каждый кадр делал три вещи: считал новую позицию, писал 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, чтобы убрать мерцание при частых перерисовках.

Сглаживание помогло, но не убрало главную цену цикла requestAnimationFrame: JavaScript по-прежнему сам рассчитывал позицию и писал transform на каждом кадре. Лента не могла двигаться без очередного вызова на главном потоке.

Движение через element.animate()

В 5.0.2 постоянное смещение ушло в WAAPI. Дорожка получает одну бесконечную линейную анимацию на дистанцию группы:

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 всё ещё нужны: обработчик прокрутки, оценка скорости и короткий цикл requestAnimationFrame (пока экспонента не сошлась).

В этой реализации нельзя убрать requestAnimationFrame после перехода на WAAPI без потери выбранного сглаживания. Без промежуточных кадров playbackRate скакал бы к цели за один шаг, даже если обновлять его правильно. Компромисс такой: браузер ведёт бесконечный сдвиг, скрипт вмешивается только пока инерция ещё жива.

Формулы скорости прокрутки и затухания дополнительного ускорения остались от 5.0.0. Добавилось сглаживание масштаба, а затем изменился способ, которым результат доходит до уже запущенной анимации.

Материалы