Есть особый тип веб-анимации, который воспринимается не как вставленное видео, а как часть самого интерфейса. Пользователь прокручивает страницу, камера обходит предмет, и каждый поворот колеса показывает следующий момент сцены. За счёт этого анимация ощущается не отдельным роликом, а продолжением самого взаимодействия со страницей.
Для такого эффекта используют image sequences — последовательность отдельных изображений, которые показываются по одному. Обычно кадры рисуют на canvas, а текущий кадр связывают со скроллом, курсором или обычным таймером. Дальше разберём, как устроен такой подход, чем он отличается от обычного видео и на что стоит обратить внимание в реальном проекте.
Так устроены многие эффектные презентации продуктов. Чтобы быстро уловить идею, можно посмотреть Pear, студийные работы Jextures, страницу Apple AirPods Pro и интерактивный пример ScrollSequence Beauty.
Что такое image sequences
Для начала полезно сравнить этот подход с обычным видео. В обычном ролике браузер получает сжатый медиапоток и декодирует его по мере воспроизведения. В image sequences каждый момент заранее представлен отдельным файлом.
frame-0001.jpg → frame-0002.jpg → frame-0003.jpg → ...
Одна сцена может быть представлена набором отдельных кадров: положение руки робота меняется постепенно от кадра к кадру.
Код выбирает нужный кадр и вызывает drawImage(). Поэтому изображение можно напрямую связать с прокруткой, остановить сцену в любой точке или сразу показать нужный кадр без перемотки медиапотока.
У такой свободы есть понятная цена: больше загрузок и выше расход памяти. Файлы кадров могут весить больше короткого видео, а после декодирования занимают в памяти заметно больше, чем на диске. Поэтому перед выбором подхода стоит понять, что для сцены важнее: точный контроль каждого кадра или простое воспроизведение ролика.
Почему не всегда подходит video
video обычно лучше подходит, если у сцены есть звук, кадров очень много, а пользователю нужно просто посмотреть ролик. В таком случае браузер сам управляет буфером и потоковой загрузкой, поэтому разработчику не приходится вручную загружать и хранить каждый кадр.
image sequences стоит выбирать, когда важнее точное соответствие жесту и кадру, остановка в произвольной точке, наложение эффектов или прозрачность отдельных кадров. Иными словами, видео удобнее для последовательного просмотра, а секвенция — для управления сценой.
| Задача | Подходящий инструмент |
|---|---|
| Ролик с аудиодорожкой | video |
| Длинная сцена с сотнями или тысячами кадров | video |
| Кадр должен следовать за прокруткой | canvas и image sequences |
| Нужна пауза в любой точке | canvas и image sequences |
| Нужны 3D-свет, частицы и геометрия | WebGL или 3D-движок |
При этом сам по себе canvas не гарантирует 60 FPS. На результат влияют разрешение кадров, скорость декодирования, объём работы в каждом кадре и, конечно, конкретное устройство. Теперь можно перейти к самой схеме и посмотреть, из каких частей она обычно состоит.
Как устроена секвенция
Если разложить такую секвенцию на части, получится довольно простая схема. Здесь нет какой-то особой магии: нужно подготовить кадры, загрузить их, выбрать текущий и вовремя нарисовать его.
- Набор URL кадров.
- Предзагрузчик, который превращает URL в готовые изображения.
canvas, на котором рисуется текущий кадр.- Часы анимации, например
requestAnimationFrame(). - Источник прогресса, например скролл страницы.
Один из практичных вариантов — подготовить три набора по 24 кадра: 1920x1080 для больших экранов, 1080x1080 для планшетов и 360x640 для телефонов. Логика анимации выбирает набор по ширине окна, загружает только нужный вариант, следит за изменением размера через ResizeObserver и рисует кадры с частотой 20 FPS. Это не единственный вариант, но он хорошо показывает общую логику.
Такое разделение важнее конкретного фреймворка: состояние можно вынести в отдельный модуль или хранить в замыкании, независимо от выбранного стека. Поэтому дальше достаточно обычного JavaScript, без привязки к конкретной библиотеке.
Минимальная реализация
Начнём с простого проигрывателя. Сначала он загрузит кадры, затем подстроит внутренний размер canvas под devicePixelRatio, а после этого запустит воспроизведение через requestAnimationFrame().
1. Подготовка Canvas и URL кадров
const canvas = document.querySelector('#sequence')
const context = canvas.getContext('2d')
const frameUrls = Array.from({ length: 24 }, (_, index) => {
const number = String(index + 1).padStart(2, '0')
return `/frames/product_${number}.jpg`
})Метод getContext('2d') возвращает 2D-контекст, через который код очищает canvas, меняет систему координат и рисует изображения.
В разметке достаточно заранее разместить canvas с идентификатором sequence. Дальше приведён только JavaScript-код, разделённый на небольшие части, чтобы было проще понять роль каждой из них.
2. Загрузка изображений
const loadImage = (url) => new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = reject
image.src = url
})
const frames = await Promise.all(frameUrls.map(loadImage))Конструктор Image создаёт изображение без добавления его в документ. После загрузки ресурса срабатывает onload, а при ошибке — onerror, поэтому функция превращает загрузку каждого кадра в промис.
Promise.all() ждёт завершения всех промисов и возвращает результаты в том же порядке, что и исходный массив URL.
3. Отрисовка текущего кадра
let frame = 0
const drawFrame = () => {
const image = frames[frame]
const scale = Math.min(
canvas.clientWidth / image.naturalWidth,
canvas.clientHeight / image.naturalHeight,
)
const width = image.naturalWidth * scale
const height = image.naturalHeight * scale
const x = (canvas.clientWidth - width) / 2
const y = (canvas.clientHeight - height) / 2
context.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight)
context.drawImage(image, x, y, width, height)
}Здесь clientWidth и clientHeight обозначают размер canvas в CSS-пикселях, а naturalWidth и naturalHeight — исходный размер изображения. Сравнив эти значения, код понимает, как вписать кадр в контейнер. Затем метод drawImage() рисует его без искажения пропорций.
drawFrame() — локальная функция из этого примера, а не встроенный Web API. Она берёт текущий элемент из массива frames, рассчитывает масштаб и передаёт изображение в context.drawImage().
4. Размер Canvas и цикл анимации
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = Math.round(canvas.clientWidth * dpr)
canvas.height = Math.round(canvas.clientHeight * dpr)
context.setTransform(dpr, 0, 0, dpr, 0, 0)
drawFrame()
}
let lastTime = 0
const frameDuration = 1000 / 20
const animate = (time) => {
if (time - lastTime >= frameDuration) {
frame = (frame + 1) % frames.length
drawFrame()
lastTime = time - ((time - lastTime) % frameDuration)
}
requestAnimationFrame(animate)
}
new ResizeObserver(resize).observe(canvas)
resize()
requestAnimationFrame(animate)ResizeObserver нужен здесь потому, что размер canvas может измениться вместе с контейнером, даже если размер окна не менялся. Наблюдатель реагирует именно на изменение элемента, а resize() заново настраивает bitmap-размер canvas и перерисовывает текущий кадр. Вызов setTransform() масштабирует систему координат под DPR, поэтому координаты отрисовки остаются в CSS-пикселях.
requestAnimationFrame() вызывается перед перерисовкой страницы и передаёт временную метку. Для анимации это удобнее, чем setInterval(), но callback срабатывает только один раз. Чтобы продолжить цикл, его нужно вызвать снова.
Проверка time - lastTime нужна, чтобы скорость не зависела от частоты монитора. Без неё на дисплее 120 Гц секвенция могла бы идти в два раза быстрее, чем на дисплее 60 Гц. delta-time заодно позволяет не догонять устаревшие кадры, если браузер пропустил один из обновлений.
Привязка к прокрутке
Для scroll-driven анимации таймер не нужен. Достаточно перевести положение секции в число от 0 до 1, а затем превратить это число в индекс кадра.
const section = document.querySelector('.sequence-section')
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
const getProgress = () => {
const bounds = section.getBoundingClientRect()
const distance = Math.max(section.offsetHeight - innerHeight, 1)
return clamp(-bounds.top / distance, 0, 1)
}
const renderFromScroll = () => {
const progress = getProgress()
frame = Math.round(progress * (frames.length - 1))
drawFrame()
}
addEventListener('scroll', renderFromScroll, { passive: true })
addEventListener('resize', renderFromScroll)Метод getBoundingClientRect() возвращает положение элемента относительно viewport. Опция passive сообщает браузеру, что обработчик scroll не будет вызывать preventDefault(), поэтому браузер может не задерживать прокрутку в ожидании обработчика.
Здесь есть один важный нюанс: обработчик скролла может срабатывать чаще, чем браузер успевает рисовать. Поэтому в production-коде лучше сохранить последнее значение прокрутки и вызывать drawFrame() не чаще одного раза за requestAnimationFrame(). Так несколько быстрых событий скролла объединяются в одно обновление экрана.
let scrollFrameRequested = false
const scheduleScrollRender = () => {
if (scrollFrameRequested) return
scrollFrameRequested = true
requestAnimationFrame(() => {
scrollFrameRequested = false
renderFromScroll()
})
}
addEventListener('scroll', scheduleScrollRender, { passive: true })В обработчиках прокрутки preventDefault() отменяет действие браузера по умолчанию, поэтому для производительного scroll-обработчика важно явно использовать passive: true, если отмена не нужна.
Предзагрузка и сеть
Promise.all(frameUrls.map(loadImage)) удобно для небольшого набора, но плохо подходит для большой сцены. Если отправить сотни запросов одновременно, они начнут конкурировать с CSS, шрифтами и основным контентом страницы.
Для более тяжёлых секвенций можно загружать кадры батчами.
const loadInBatches = async (urls, batchSize = 4) => {
const images = []
for (let index = 0; index < urls.length; index += batchSize) {
const batch = urls.slice(index, index + batchSize)
images.push(...await Promise.all(batch.map(loadImage)))
}
return images
}На практике удобно действовать поэтапно.
- Сначала загрузить первый кадр, чтобы блок не выглядел пустым.
- Затем загрузить несколько кадров, которые пользователь увидит в начале.
- Остальные кадры догрузить после появления основного контента.
Формат файлов лучше выбирать под саму сцену. JPEG хорошо подходит для фотографических кадров, WebP и AVIF часто помогают уменьшить размер, а PNG нужен только там, где действительно важны прозрачность или резкие плоские цвета. Все кадры одной секвенции должны иметь одинаковый размер и одинаковое кадрирование. В документации ScrollSequence также советуют заранее уменьшать разрешение и подбирать степень сжатия под задачу.
Адаптивное качество

Один и тот же визуальный материал можно подготовить под разные размеры контейнера.
Один набор кадров для всех устройств почти всегда означает лишнюю загрузку на телефоне. Проще заранее подготовить несколько вариантов и выбирать их по ширине контейнера и возможностям устройства. На небольших экранах это уменьшает объём загрузки, а на больших позволяет сохранить хорошее качество изображения.
| Вариант | Пример разрешения | Когда выбирать |
|---|---|---|
| Desktop | 1920x1080 | Большое окно и мощное устройство. |
| Tablet | 1080x1080 | Средняя ширина или квадратная композиция. |
| Mobile | 360x640 | Узкий экран и ограниченный канал. |
Выбирать вариант стоит по ширине контейнера, а не только по window.innerWidth. Ведь компонент может находиться в модальном окне или в колонке, которая занимает лишь часть экрана.
const getVariant = (width) => {
if (width <= 560) return 'mobile'
if (width <= 1080) return 'tablet'
return 'desktop'
}devicePixelRatio делает изображение резче, но одновременно увеличивает размер буфера. Поэтому его разумно ограничивать, например значением 2, и не умножать огромный canvas на высокий DPR дорогого монитора. Иначе память и время отрисовки будут расти почти незаметно, пока устройство не начнёт заметно тормозить.
Масштабирование кадра

contain сохраняет объект целиком, а cover заполняет контейнер и может обрезать края.
Режим contain показывает весь кадр и оставляет поля, если пропорции не совпадают. Режим cover заполняет контейнер целиком, но может обрезать края.
const scale = objectFit === 'cover'
? Math.max(width / image.naturalWidth, height / image.naturalHeight)
: Math.min(width / image.naturalWidth, height / image.naturalHeight)На визуальных сайтах поверх canvas часто добавляют градиент или виньетку. Это помогает аккуратно спрятать края композиции, но такой слой не должен перехватывать события указателя или заменять корректное object-fit, если пользователю важно рассмотреть сам продукт. Проще говоря, декоративный слой должен помогать сцене, а не мешать взаимодействию с ней.
Для CSS-режимов contain и cover используется свойство object-fit.
Память и декодирование
На диске JPEG может занимать совсем немного места, но браузер рисует уже декодированное изображение. Поэтому объём файлов на диске не показывает, сколько памяти реально понадобится во время работы. Для быстрой оценки памяти несжатого RGBA-кадра можно использовать такую формулу.
память одного кадра ≈ ширина × высота × 4 байтаДля 60 кадров размером 1920x1080 получится около 475 MiB, не считая внутренних накладных расходов браузера. Поэтому «шестьдесят маленьких JPEG» не означают, что памяти потребуется столько же, сколько весят файлы.
Если кадров становится много, можно действовать по-разному. Вот несколько рабочих вариантов.
- Хранить в памяти только окно кадров вокруг текущей позиции.
- Использовать несколько разрешений и загружать нужный вариант.
- Уменьшить разрешение самого
canvasи кадров. - Перейти на
video, если покадровый контроль больше не нужен. - Рассмотреть
createImageBitmap()после измерений на целевых устройствах.
createImageBitmap() не является волшебной кнопкой ускорения. Он может перенести декодирование в подходящий для браузера механизм, но добавляет собственные расходы и не решает проблему общего объёма памяти. Поэтому его стоит добавлять только после измерений на целевых устройствах.
Доступность и управление движением
Большая анимация не должна быть единственным способом понять продукт. Даже если эффект выглядит впечатляюще, рядом стоит оставить короткое описание, статичный первый кадр или текстовый блок с главной информацией.
Учитывайте настройку пользователя prefers-reduced-motion, которая позволяет узнать, предпочитает ли пользователь уменьшенное количество движения.
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
if (reduceMotion) {
frame = 0
drawFrame()
} else {
requestAnimationFrame(animate)
}Вызов matchMedia() проверяет, соответствует ли текущий viewport указанному медиа-условию. Если пользователь включил уменьшение движения, пример показывает первый кадр и не запускает цикл анимации.
Автовоспроизведение тоже лучше оставить под контролем пользователя. Для длинной сцены пригодятся кнопки паузы и продолжения. Для scroll-driven варианта полезно дать возможность увидеть статичный кадр и прочитать соседний текст без обязательной прокрутки.
Отладка
Проверяйте не только средний FPS, но и разные сценарии: первую загрузку, изменение размера окна, поворот телефона, возврат на вкладку и повторный вход в секцию. Именно в таких местах чаще всего проявляются проблемы с памятью и синхронизацией.
В Chrome DevTools откройте панель Performance и запишите несколько таких сценариев. В записи смотрите на длинные Animation Frame Fired, красные dropped frames, время выполнения JavaScript и нагрузку на рендеринг. Раздел Frames помогает понять, успел ли кадр отрисоваться, был ли показан частично или пропущен. Общие принципы измерения частоты кадров и стоимости рендеринга собраны в руководстве MDN по производительности анимаций.
Обязательно проверяйте секвенцию на реальном телефоне. Десктопный MacBook может без проблем отрисовывать большой canvas, тогда как бюджетный Android-смартфон быстро упрётся в ограничения. Поэтому следите за сетевой нагрузкой, количеством декодированных изображений и использованием памяти именно на тех устройствах, которыми пользуется ваша аудитория.
Итог
Image Sequences — это не просто «видео из картинок», а способ сделать время частью интерфейса. Такой подход особенно хорош для продуктовых историй, где пользователь сам задаёт момент сцены прокруткой или жестом.
Хорошая реализация начинается не с выбора библиотеки, а с простых вопросов: сколько кадров действительно нужно, какого разрешения хватит, можно ли загружать их постепенно и что увидит пользователь при отключённой анимации. После этого остаётся связать кадры с canvas, синхронизировать обновления через requestAnimationFrame() и проверить результат на реальных устройствах.
