---
title: "Бегущая строка без рывков: marquee на CSS"
description: "Бегущая строка выглядит как одна из самых простых вещей во фронтенде — лента с логотипами или тегами, которая бесконечно едет вбок. На деле это одна…"
url: "https://rowan-web.ru/blog/begushhaya-stroka-bez-ryvkov-kak-sdelat-besshovnyj-marquee-na-css/"
date_modified: "2026-09-06T06:44:47+03:00"
language: "ru-RU"
---
Бегущая строка выглядит как одна из самых простых вещей во фронтенде — лента с логотипами или тегами, которая бесконечно едет вбок. На деле это одна из тех задач, где почти у всех с первого раза получается не то: лента либо дёргается в момент, когда должна «перемотаться» обратно к началу, либо садится в кадре, стоит странице заняться чем-то ещё. Подтверждение «наболевшей» проблемы говорит еще и тот момент, что в одном из тестовых заданий на позицию фронтендера у Яндекс.Cloud отдельным пунктом стоит требование — лента должна крутиться без дёрганий. Отдельным пунктом такое пишут только тогда, когда знают, что у большинства не получается с первого раза.

Почему обычно дёргается
-----------------------

Первый инстинктивный путь — таймер, который на каждый тик двигает элемент через margin-left, left или scrollLeft. Это сразу два источника дёрганий разом: во-первых, margin/left/scrollLeft — layout-свойства, каждое изменение заставляет браузер заново пересчитать раскладку и перерисовать кадр, а не просто передвинуть уже готовый слой на видеокарте. Во-вторых, таймер живёт в основном потоке — стоит странице отвлечься на что угодно ещё (загрузка данных, другой скрипт, тяжёлый reflow где-то рядом), и лента на глазах спотыкается.

Второй path — CSS-анимация, но с двумя типичными промахами. Первый: величину сдвига на бесшовный повтор считают без учёта gap между карточками — из-за этого в момент, когда анимация «перематывается» на цикл заново, картинка не совпадает с исходной на пиксель, и это видно как микро-скачок. Второй: оставляют дефолтный easing вместо linear — на разовой анимации разница незаметна, а на бесконечной с infinite каждый повторный цикл заново проигрывает ту же кривую ускорения-замедления, и лента на стыке каждого цикла ощутимо «спотыкается», хотя технически ничего не сломано.

Идея: сдвиг ровно на одну копию контента, дальше — по кругу
-----------------------------------------------------------

Смотрел, как это сделано у меня на одной из карточек услуги — бегущая строка тегов внизу блока. Сначала JS клонирует исходный набор элементов столько раз, чтобы суммарная ширина ленты гарантированно перекрывала ширину экрана плюс ещё одну копию с запасом:

```
function initRealMarquee() {
    const track = document.querySelector('.real_marquee_track');
    if (!track) return;

    const originalItems = Array.from(track.children);
    const originalWidth = track.getBoundingClientRect().width;
    if (!originalWidth) return;

    const gap = parseFloat(getComputedStyle(track).columnGap) || 0;
    const stepWidth = originalWidth + gap;

    let totalWidth = originalWidth;
    const minWidth = window.innerWidth + stepWidth;

    while (totalWidth < minWidth) {
        originalItems.forEach(item => track.appendChild(item.cloneNode(true)));
        totalWidth += stepWidth;
    }

    track.style.setProperty('--real-offset', `-${stepWidth}px`);
    track.classList.add('is-ready');
}
```

stepWidth — это и есть тот самый шаг, на который анимация должна сдвинуть ленту, чтобы копия #2 встала ровно там, где перед этим была копия #1. columnGap здесь не для красоты: если посчитать шаг только по ширине блока с исходными элементами, без зазора между ними, копии лягут вплотную друг к другу на пиксель не там, где надо, и на стыке цикла будет заметный скачок. Ширина считается один раз, в реальных пикселях уже отрисованного DOM — а не предположением о том, сколько там элементов и какой у них шрифт.

Анимация — только transform, только linear
------------------------------------------

Сдвиг применяется не через layout-свойство, а через CSS-переменную и transform: translateX, который браузер анимирует на отдельном composite-слое, без пересчёта раскладки на каждый кадр — тот же принцип, по которому GPU тянет прокрутку страницы, не трогая основной поток:

```
.real_marquee_track {
  display: flex;
  align-items: center;
  width: max-content;
  will-change: transform;
}

.real_marquee_track.is-ready {
  animation: marqueeScroll 20s linear infinite;
}

@keyframes marqueeScroll {
  from {
    transform: translateX(var(--real-offset));
  }
  to {
    transform: translateX(0);
  }
}
```

linear здесь не опция на вкус, а обязательное условие: с ease или ease-in-out каждый повтор infinite заново проигрывает кривую ускорения — на однократной анимации это незаметно, а на бесконечной ощущается как ритмичное заикание в одной и той же точке каждого цикла. И поскольку копия #2 в разметке — точный клон копии #1 (тот самый originalItems.forEach(item => track.appendChild(item.cloneNode(true))) выше), кадр в конце анимации (translateX(0), видна копия #1) выглядит пиксель-в-пиксель как кадр в начале следующего цикла (translateX(—real-offset), тем же местом видна копия #2) — стык цикла физически невозможно заметить.

Класс is-ready навешивается JS-ом только после того, как клоны уже добавлены в DOM и переменная —real-offset посчитана. Если бы анимация могла запуститься раньше — по умолчанию в CSS, без ожидания скрипта — она стартовала бы с шагом, посчитанным для пустой ещё не задублированной ленты, и как только JS довешивал бы копии, лента дёрнулась бы на новый шаг прямо на глазах. Так что сам факт, что «рывок» почти всегда возникает именно в момент старта или на стыке цикла — не случайность, это ровно то место, где расходятся расчётный шаг и то, что на самом деле нарисовано в DOM.

Что это даёт
------------

- Анимация целиком на композитном слое — лента не спотыкается, даже если основной поток занят чем-то ещё.
- Ни одного таймера в JS: весь бесконечный цикл — это одна CSS-анимация, посчитанная один раз при инициализации.
- Стык цикла невидим не потому что «плавно», а потому что кадр в конце и кадр в начале следующего повтора физически идентичны — одинаковые клоны, сдвинутые на точно посчитанный шаг.
- Раз посчитанный при инициализации шаг не пересчитывается на resize — на сильном изменении ширины окна раскладка может разойтись с шагом анимации; для ленты тегов, которая обычно не открыта во время ресайза окна, это осознанный компромисс, а не то, что стоит копировать не глядя в компонент, чувствительный к живому resize.

Если нужно, чтобы лента ехала в обратную сторону — меняются местами from и to в keyframes, больше ничего трогать не надо. А то, что подобное требование в принципе попадает в тестовые задания — хороший стимул чтобы вникнуть в механизм работы. (Также как и про замыкания в JS, о которых обязательно любят спрашивать на собесах…)

[Полный список страниц сайта, читаемых AI](https://rowan-web.ru/llms.txt)
