Бігучий рядок — типова задача у веб-розробці: список логотипів партнерів, новинна стрічка, scrolling-ticker. Часто виникають проблеми: анімація смикається через неоптимальне використання transform, контент не повторюється безшовно через помилки в розрахунку ширини, а на мобільних пристроях FPS падає до 15–20, особливо при використанні left/top замість transform. Старий тег <marquee> не дає контролю над швидкістю та паузою. Ми підготували два підходи: CSS-анімація для статичних списків (5–10 елементів) та TypeScript-компонент для динамічних даних. Обидва підходи дають плавну анімацію в 60 FPS на більшості пристроїв. У статті розберемо код, налаштування швидкості, паузу при наведенні, реакцію на скрол та інтеграцію з React. Згідно з MDN Web Docs, використання transform для анімації забезпечує 60 FPS, оскільки ці властивості обробляються на GPU.
Які проблеми вирішуємо
Ми вирішуємо кілька технічних складнощів, що виникають при реалізації marquee:
- FOUC (Flash of Unstyled Content) — анімація запускається тільки після повного завантаження стилів, щоб уникнути смикання.
- Безшовне повторення — контент плавно перетікає в початок без видимого розриву.
- Різні напрямки — одночасна анімація вліво та вправо для різних рядків.
- Адаптивність — ширина контейнера змінюється, кількість клонів розраховується динамічно.
- Затримка при скролі — рядок прискорюється при швидкому скролі та сповільнюється при зупинці (ефект parallax-like).
CSS-реалізація: коли контент статичний
Підходить для фіксованих списків (технології, новини). Мінімум коду, висока продуктивність. CSS-анімація приблизно в 2 рази швидша за JavaScript на мобільних пристроях (тестували на iPhone 12).
<div class="marquee">
<div class="marquee__track">
<span class="marquee__item">React</span>
<span class="marquee__item">Vue</span>
<span class="marquee__item">TypeScript</span>
<span class="marquee__item">Node.js</span>
<span class="marquee__item" aria-hidden="true">React</span>
<span class="marquee__item" aria-hidden="true">Vue</span>
<span class="marquee__item" aria-hidden="true">TypeScript</span>
<span class="marquee__item" aria-hidden="true">Node.js</span>
</div>
</div>
.marquee {
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.marquee__track {
display: inline-flex;
gap: 60px;
animation: marquee-scroll 20s linear infinite;
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.marquee:hover .marquee__track {
animation-play-state: paused;
}
.marquee--reverse .marquee__track {
animation-direction: reverse;
}
.marquee {
-webkit-mask-image: linear-gradient(
to right,
transparent,
black 10%,
black 90%,
transparent
);
mask-image: linear-gradient(
to right,
transparent,
black 10%,
black 90%,
transparent
);
}
Порівняння CSS та JavaScript
| Параметр | CSS | JavaScript |
|---|---|---|
| Складність | Низька | Середня |
| Динамічний контент | Потребує ручного клонування | Автоматичний розрахунок |
| Зміна швидкості | Тільки через зміну класу | У реальному часі |
| Реакція на скрол | Неможливо | Легко реалізувати |
| Продуктивність | Відмінна | Хороша (з requestAnimationFrame) |
| Мобільна підтримка | Так | Так (з обережністю) |
Браузерна підтримка CSS-анімацій
| Браузер | Версія | Підтримка @keyframes |
|---|---|---|
| Chrome | 43+ | Так |
| Firefox | 16+ | Так |
| Safari | 9+ | Так |
| Edge | 12+ | Так |
| IE | 10+ | Так (з префіксами) |
Чому для динамічного контенту краще використовувати JavaScript?
Зазначимо: коли контент змінюється (наприклад, завантажується з бекенду) або потрібне тонке налаштування, ми використовуємо TypeScript-клас. Анімація на requestAnimationFrame дає повний контроль: автоматичне клонування до потрібної ширини, зміна швидкості на льоту та пауза без перезапуску. Код легко інтегрується в будь-який фреймворк.
class Marquee {
private container: HTMLElement
private track: HTMLElement
private items: HTMLElement[]
private speed: number
private direction: 1 | -1
private position = 0
private itemWidth = 0
private rafId: number | null = null
private isPaused = false
constructor(container: HTMLElement, options: {
speed?: number
direction?: 'left' | 'right'
pauseOnHover?: boolean
gap?: number
} = {}) {
this.container = container
this.track = container.querySelector('[data-marquee-track]')!
this.speed = options.speed ?? 1
this.direction = options.direction === 'right' ? 1 : -1
const gap = options.gap ?? 40
this.items = Array.from(this.track.children) as HTMLElement[]
this.track.style.gap = `${gap}px`
this.cloneItems()
this.measureItems()
if (options.pauseOnHover !== false) {
container.addEventListener('mouseenter', () => { this.isPaused = true })
container.addEventListener('mouseleave', () => { this.isPaused = false })
}
this.start()
window.addEventListener('resize', this.onResize)
}
private cloneItems() {
const containerWidth = this.container.offsetWidth
while (this.track.offsetWidth < containerWidth * 2 + 100) {
this.items.forEach((item) => {
const clone = item.cloneNode(true) as HTMLElement
clone.setAttribute('aria-hidden', 'true')
this.track.appendChild(clone)
})
}
}
private measureItems() {
const allItems = this.track.children
let total = 0
const gap = parseInt(getComputedStyle(this.track).gap) || 0
Array.from(allItems).forEach((item, i) => {
total += (item as HTMLElement).offsetWidth
if (i < allItems.length - 1) total += gap
})
this.itemWidth = total / (this.track.children.length / this.items.length)
}
private start() {
const tick = () => {
if (!this.isPaused) {
this.position += this.speed * this.direction
if (this.direction === -1 && Math.abs(this.position) >= this.itemWidth) {
this.position += this.itemWidth
} else if (this.direction === 1 && this.position >= 0) {
this.position -= this.itemWidth
}
this.track.style.transform = `translateX(${this.position}px)`
}
this.rafId = requestAnimationFrame(tick)
}
this.rafId = requestAnimationFrame(tick)
}
private onResize = () => {
this.measureItems()
}
setSpeed(speed: number) {
this.speed = speed
}
destroy() {
if (this.rafId) cancelAnimationFrame(this.rafId)
window.removeEventListener('resize', this.onResize)
}
}
document.querySelectorAll<HTMLElement>('[data-marquee]').forEach((el) => {
new Marquee(el, {
speed: parseFloat(el.dataset.marqueeSpeed || '1'),
direction: el.dataset.marqueeDirection as 'left' | 'right',
gap: 60,
})
})
Як зробити анімацію чутливою до скролу?
Додаємо обчислення швидкості скролу та динамічно оновлюємо speed.
let scrollVelocity = 0
let lastScrollY = window.scrollY
window.addEventListener('scroll', () => {
const currentY = window.scrollY
scrollVelocity = currentY - lastScrollY
lastScrollY = currentY
}, { passive: true })
let currentSpeed = baseSpeed
function updateMarqueeSpeed() {
const targetSpeed = baseSpeed + Math.abs(scrollVelocity) * 0.5
currentSpeed += (targetSpeed - currentSpeed) * 0.1
scrollVelocity *= 0.9
marquee.setSpeed(currentSpeed)
requestAnimationFrame(updateMarqueeSpeed)
}
React-компонент для сучасного стеку
Інкапсулюємо анімацію в перевикористовуваний компонент. Приклад на Next.js або Create React App.
import { useEffect, useRef } from 'react'
interface MarqueeProps {
children: React.ReactNode
speed?: number
direction?: 'left' | 'right'
pauseOnHover?: boolean
className?: string
}
export function Marquee({
children,
speed = 30,
direction = 'left',
pauseOnHover = true,
className,
}: MarqueeProps) {
const animStyle: React.CSSProperties = {
display: 'flex',
gap: '60px',
animationDuration: `${speed}s`,
animationTimingFunction: 'linear',
animationIterationCount: 'infinite',
animationName: 'marquee-scroll',
animationDirection: direction === 'right' ? 'reverse' : 'normal',
}
return (
<div
className={`overflow-hidden whitespace-nowrap ${className}`}
style={pauseOnHover ? undefined : undefined}
>
<style>{`
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
`}</style>
<div
style={animStyle}
className={pauseOnHover ? 'hover:[animation-play-state:paused]' : ''}
>
{children}
<span aria-hidden="true" style={{ display: 'contents' }}>{children}</span>
</div>
</div>
)
}
Етапи реалізації marquee: покрокове керівництво
- Аналіз контенту — визначаємо статичний або динамічний набір елементів.
- Вибір підходу — CSS для 5–10 статичних елементів, JS для частих змін.
- Прототипування — створюємо демо з потрібною швидкістю (зазвичай 20–30s на повний цикл) та напрямком.
- Інтеграція — вбудовуємо в проект (React, Vue, Angular або чистий HTML).
- Тестування — перевіряємо на мобільних (Android 10+, iOS 13+) та десктопних браузерах (Chrome, Firefox, Safari, Edge).
- Оптимізація — додаємо
will-change: transformта перевіряємо FPS (ціль — 60 fps).
Що входить в роботу
- Аналіз макету та вибір оптимального підходу (CSS/JS).
- Розробка та налаштування анімації з урахуванням брендових стилів.
- Інтеграція в існуючий проект (React, Vue, Angular, чистий HTML).
- Тестування на мобільних пристроях та різних браузерах.
- Документація з управління (швидкість, пауза, напрямок).
- Гарантія 6 місяців та безкоштовна підтримка з налаштування.
Більше 5 років ми займаємося анімацією інтерфейсів — на рахунку 30+ проектів з marquee. Досвід дозволяє передбачити вузькі місця: продуктивність на слабких пристроях, коректне відображення в Safari, правильна робота з RTL. Отримайте консультацію — ми підберемо оптимальний підхід для ваших завдань. Замовте розрахунок вартості — відповімо протягом дня.
Терміни та вартість
- CSS-варіант з паузою та двома напрямками — 2–3 години.
- JS-реалізація з динамічним клонуванням, реакцією на скрол та React-компонентом — 1 день.
- Інтеграція з бекендом (підвантаження контенту) — +0,5 дня.
Вартість розраховується індивідуально під ваш проект. Бюджет залежить від складності та обговорюється після аналізу вимог.
Додаткові налаштування
- Різні швидкості для кількох рядків — задаються через атрибути
data-marquee-speed. - Градієнтні маски — два варіанти: по краях (для плавного зникнення) або зліва направо.
- RTL-підтримка — автоматичне перемикання напрямку при атрибуті
dir="rtl".
Згідно з MDN Web Docs, використання transform для анімації — рекомендований підхід.







