playbackRate не вывозит

08 Авг 2026

В mode="scroll" лента должна ускоряться вместе со страницей и сохранять инерцию, когда прокрутка уже остановилась. После перехода на Web Animations API (WAAPI) скорость можно менять через playbackRate. На бумаге это ровно то, что нужно — положительное значение двигает анимацию вперёд, отрицательное — назад, ноль останавливает движение.

На практике при смене направления лента дёргалась. Проверял в разных браузерах на Windows, iOS и Android. Рывок возникал во всех проверенных браузерах. Исправлять пришлось не формулу скорости, а способ её обновления у уже запущенной анимации.

Разберу, как режим scroll в marquee-content@5.0.3 дошёл от ручного requestAnimationFrame до element.animate() и почему обычное присваивание playbackRate пришлось заменить на updatePlaybackRate().

После GSAP

В 5.0.0 компонент снова стал Custom Element. Я убрал GSAP как лишнюю зависимость и лишний вес. Постоянное движение (mode="auto") ушло в CSS @keyframes. Скорость задаётся в px/s, а не длительностью полного цикла.

Импорт пакета автоматически регистрирует Custom Element:

import 'marquee-content';

После этого можно добавить его в разметку:

<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 и переводит её в добавку к масштабу времени. В этой формуле deltaY — смещение в пикселях, а deltaTime — интервал в секундах:

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

Проблема проявлялась при обновлении уже запущенной анимации. Сглаживание меняло масштаб десятки раз за разворот, и при каждом таком изменении код заново присваивал playbackRate.

Спецификация Web Animations описывает это прямо. Установка playbackRate — синхронное обновление без попытки согласовать состояние с анимацией, которая может идти в другом потоке или процессе. В результате уже запущенная анимация может скачком перейти к другой позиции. Для смены скорости без такого скачка предусмотрен асинхронный updatePlaybackRate().

Смена направления как раз меняет знак масштаба. Инерция ещё не дошла до нуля, целевое значение уже отрицательное, сглаживание переводит currentTimeScale через ноль. На этом участке присваивание повторялось каждый кадр и давало заметный рывок.

Возврат к ручному offset снова заставил бы главный поток писать transform. Дополнительное сглаживание тоже не устраняло причину: оно лишь увеличивало число записей в playbackRate.

updatePlaybackRate() сохраняет позицию

В 5.0.3 заменил присваивание в покадровом обновлении вызовом updatePlaybackRate():

if (this.scrollAnimation.playbackRate !== this.currentTimeScale) {
  this.scrollAnimation.updatePlaybackRate(this.currentTimeScale);
}

Метод согласовывает смену скорости с текущей позицией анимации. У работающей анимации playbackRate обновляется асинхронно. Завершения обновления можно дождаться через Promise ready. Для разворота это и нужно: лента не прыгает к другому кадру цикла, меняется только темп.

Создание анимации по-прежнему начинается с присваивания. Здесь playbackRate задаёт стартовую скорость вместе с начальным currentTime.

При повторной ручной проверке того же сценария — прокрутки вверх и вниз на телефоне и на ПК — рывка уже не было. До замены присваивания он возникал во всех проверенных браузерах.

Что осталось на JavaScript

В mode="auto" JavaScript почти не участвует в самом движении: ему остаются пауза, prefers-reduced-motion и пересборка дорожки. Режиму scroll всё ещё нужны обработчик прокрутки, оценка скорости и короткий цикл requestAnimationFrame.

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

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

Материалы