How to Animate Multiple Marquee Elements Independently in JavaScript
Why Do Multiple Marquees Move in Sync?
Recreating the classic HTML <marquee> element with modern JavaScript is a fun project, but a common bug occurs when trying to animate multiple marquee elements simultaneously. If you notice shorter lines of text snapping or bouncing back at the exact moment the longest line hits the container edge, the reason is almost always shared state.
In the original implementation, the position (pos) and direction (dir) variables are declared globally outside the loop:
let pos = 0;
let dir = 1;
function marquee() {
for (let i = 0; i < marqueeContainers.length; i++) {
// Both lines modify the same pos and dir values
}
}Because pos and dir are shared across every marquee element, the collision checks in the loop overwrite each other during every frame. To fix this, each marquee line must track its own position, direction, and dimensions independently.
Solution 1: Encapsulate State Using JavaScript Classes (Recommended)
The cleanest and most scalable way to solve this in JavaScript is by creating a dedicated class or constructor function for each marquee element. This encapsulates the state so that every instance runs its own animation loop with isolated variables.
We will also use CSS transform: translateX() instead of left to improve rendering performance and avoid layout thrashing.
JavaScript Implementation
class Marquee {
constructor(container) {
this.container = container;
this.text = container.querySelector('.marquee_text');
this.pos = 0;
this.dir = 1; // 1 = right, -1 = left
this.speed = 1.5; // Custom speed in pixels per frame
// Bind the loop function to keep the correct 'this' context
this.animate = this.animate.bind(this);
this.animate();
}
animate() {
const containerWidth = this.container.offsetWidth;
const textWidth = this.text.offsetWidth;
const maxOffset = containerWidth - textWidth;
// Only bounce if the text is smaller than container
if (maxOffset > 0) {
this.pos += this.dir * this.speed;
if (this.pos >= maxOffset) {
this.pos = maxOffset;
this.dir = -1;
} else if (this.pos <= 0) {
this.pos = 0;
this.dir = 1;
}
}
// Using translateX offers much smoother 60fps performance
this.text.style.transform = `translateX(${this.pos}px)`;
requestAnimationFrame(this.animate);
}
}
// Initialize all marquees independently
document.querySelectorAll('.marquee_container').forEach((container) => {
new Marquee(container);
});HTML and CSS
<div class="marquee_container">
<span class="marquee_text">Short line of text.</span>
</div>
<p>Regular static paragraph between marquees.</p>
<div class="marquee_container">
<span class="marquee_text">A much longer sentence that bounces at its own boundary!</span>
</div>.marquee_container {
width: 100%;
overflow: hidden;
white-space: nowrap;
border: 1px solid #ccc;
margin-bottom: 1rem;
}
.marquee_text {
display: inline-block;
will-change: transform;
}Solution 2: Keep a Single Animation Loop with an Array of Objects
If you prefer having a centralized loop rather than multiple requestAnimationFrame instances, you can map each DOM element to an internal state object:
const marquees = Array.from(document.querySelectorAll('.marquee_container')).map((container) => ({
container,
text: container.querySelector('.marquee_text'),
pos: 0,
dir: 1,
speed: 1.5
}));
function loop() {
marquees.forEach((item) => {
const maxOffset = item.container.offsetWidth - item.text.offsetWidth;
if (maxOffset > 0) {
item.pos += item.dir * item.speed;
if (item.pos >= maxOffset) {
item.pos = maxOffset;
item.dir = -1;
} else if (item.pos <= 0) {
item.pos = 0;
item.dir = 1;
}
item.text.style.transform = `translateX(${item.pos}px)`;
}
});
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);Bonus: Alternative Pure CSS Approach
If you do not strictly require JavaScript collision detection and just want smooth, continuous scrolling, modern CSS can achieve independent animations without any script overhead:
@keyframes scrollLeft {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}
.marquee_text {
display: inline-block;
animation: scrollLeft 10s linear infinite;
}Summary
- The Cause: Multiple elements sharing global variables (
pos,dir) conflict with each other's calculations. - The Fix: Encapsulate state using a class or an array of stateful objects.
- Performance Tip: Animate
transform: translateX()instead of CSSleftto leverage GPU acceleration and prevent unnecessary layout reflows.