D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

CSS Marquee Looping With a Huge Empty Gap? `translateX(-50%)` Needs a Track Twice the Viewport

· · 7 min read
CSS Marquee Looping With a Huge Empty Gap? `translateX(-50%)` Needs a Track Twice the Viewport

A marquee looks like one of the easiest effects on the web. One keyframe, one translateX, done. Then you open it on a wide monitor and notice a hole the size of half the screen showing up every few seconds, like a train missing a carriage.

That is exactly what happened to me while reworking the announcement bar on an online store. It used to be a carousel that faded between messages. The request was simple: turn it into text that scrolls continuously without stopping.

The symptom: smooth on a laptop, full of holes on a wide screen

The animation ran. The console was clean. On my laptop the effect looked reasonable, just a little airy. It was only on a large monitor that the problem got obvious: text passes by, then a long stretch of nothing, then text arrives again from the right. Not a marquee that flows, but a marquee that flickers between having content and having none.

The starting structure was the one every tutorial uses. The track contents were duplicated in the template, then the track was shifted by half its own width:

{% for pass in (1..2) %}
  {% for message in section.blocks %}
    <span class="marquee__item">{{ message.settings.text }}</span>
  {% endfor %}
{% endfor %}
[data-marquee] { overflow: hidden; }
 
[data-marquee-track] {
  display: inline-flex;
  width: max-content;
  white-space: nowrap;
  animation: marquee var(--duration, 30s) linear infinite;
}
 
@keyframes marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

The logic is sound. If the first half of the track is identical to the second half, shifting by 50 percent lands on a position that looks the same as the starting point, so the loop has no visible seam. That part was never wrong.

The root cause: a width problem wearing an animation costume

At the time the store had exactly one short message running, roughly the length of "Free shipping". After the template duplicated it, the whole track was about 600 pixels wide. The viewport was 2000 pixels.

So here is what was actually happening:

The real requirement is not "duplicate the content". It is that the track must be at least twice the width of the viewport. Below that, no amount of translateX can cover the screen, because there simply is not enough content to cover it with.

That is where the trap sits: static duplication in a template can never guarantee the requirement. How many copies you need depends on two things you only learn at runtime, namely how many messages the shop owner typed into the admin and how wide the visitor's screen is. One short message on a 2560px monitor needs far more copies than five long messages on a phone. The (1..2) in the template was just a guess that happened to look fine on my screen.

There is a second, subtler trap. When I first tried measuring and topping up the copies in JavaScript, the track still came out too short on some visits. The measurement was running before the web fonts had finished loading. The text was still rendered in a narrower fallback font, so the scrollWidth I read back was smaller than the real width. Once the real font arrived and the text grew, a track that had been "long enough" became too short again.

The fix: clone until full, not clone once

The fix moves the decision about copy count out of the template and into runtime. JavaScript measures, then doubles the track contents repeatedly until the length passes twice the viewport width.

const viewport = document.querySelector('[data-marquee]');
const track = viewport.querySelector('[data-marquee-track]');
const speed = 60; // pixels per second
 
// capture the original contents once
const originals = Array.from(track.children).map((n) => n.cloneNode(true));
 
function apply() {
  // 1. reset the track back to its original contents
  track.replaceChildren(...originals.map((n) => n.cloneNode(true)));
 
  // 2. double everything until it is long enough
  const target = viewport.offsetWidth * 2;
  let safety = 0;
  while (track.scrollWidth < target && safety < 12) {
    const snapshot = Array.from(track.children).map((n) => n.cloneNode(true));
    snapshot.forEach((n) => track.appendChild(n));
    safety++;
  }
 
  // 3. duration follows the track length instead of a fixed number
  const duration = Math.max(8, (track.scrollWidth / 2) / speed);
  track.style.animationDuration = duration + 's';
}
 
document.fonts.ready.then(() => requestAnimationFrame(apply));

There are four decisions packed into that small function, and all of them matter.

Reset to the originals first. Without the reset the copy count only ratchets up, it never comes back down. Someone who drags the window from a wide monitor to a narrow one leaves behind a track still sized for the widest measurement it ever took, with every extra copy still being animated. Capturing originals up front means every call starts from the same state.

Double the whole track, do not append a single copy. This is not a style preference. Doubling everything keeps the contents split into two identical halves, which is precisely the condition that makes translateX(-50%) land exactly on the seam. Append one copy per iteration and you can easily end up with an odd number of copies, which puts the loop point in the wrong place. As a bonus, doubling grows exponentially, so even the shortest content needs only a few iterations to cover the widest screen.

Keep a safety cap. The safety < 12 guard exists so the loop can never become infinite. If the track's scrollWidth somehow never grows, say the track itself is display: none while its wrapper still has width, that guard is the only thing standing between you and a frozen tab.

Derive the duration from a speed, not a constant. This is the part people skip. Lock in animation-duration: 30s and the perceived speed of the marquee changes with the length of the content. One short message crawls, five long ones sprint. By fixing a speed in pixels per second and dividing the distance travelled, which is half the track width, the pace a visitor sees stays the same no matter what is inside. The Math.max(8, ...) keeps the loop from getting so short that it feels twitchy.

Finally, all of it runs after document.fonts.ready so the measurement uses real text widths, wrapped in requestAnimationFrame so the layout read happens after the browser has settled.

Do not forget resize

The whole calculation hangs on viewport.offsetWidth, so the moment the window changes width the number is stale. Someone rotates a phone, someone drags the window onto a second monitor, and the empty gap is back.

let resizeTimer;
window.addEventListener('resize', () => {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(apply, 200);
});

The debounce is not optional. apply reads scrollWidth and writes to the DOM, so calling it on every resize event forces the browser to recompute layout dozens of times a second while you drag.

What I took away