Visit

An infinite, draggable marqueein Vue

The short answer

Render the content three times inside a horizontally scrollable element, park scrollLeft on the middle copy, and jump by exactly one copy's width when it drifts too far. Using native scrollLeft rather than a transform means touch swipes, trackpads and wheels work without any extra code.

Why three copies

Sitting on the middle copy leaves a whole copy of headroom on either side, so the track can be dragged in both directions before it has to jump. The jump is invisible because the copies are identical. It only works if one copy is exactly scrollWidth divided by the number of copies, so the gap between cards belongs inside each item rather than on the flex track.

const wrap = () => {
  const viewport = viewportRef.value;
  if (!unit || unit <= viewport.clientWidth) return;
  const wrapped = unit + gsap.utils.wrap(0, unit, viewport.scrollLeft - unit);
  const shift = wrapped - viewport.scrollLeft;
  if (Math.abs(shift) >= 1) {
    viewport.scrollLeft = wrapped;
    startScroll += shift;
  }
  expected = viewport.scrollLeft;
  position = viewport.scrollLeft;
  subpixel(0);
};

Keep a slow drift smooth

Browsers can snap scrollLeft to whole pixels, which turns a slow drift into a one-pixel stair-step. Keep the fractional position in a variable, and let the leftover fraction ride on a transform.

position += (props.speed * drift.factor * delta) / 1000;
if (position >= unit * 2) position -= unit;
viewport.scrollLeft = position;
expected = viewport.scrollLeft;
subpixel(expected - position);

Tell a drag from a click

Every card is a link, so a drag must not navigate. Count anything past five pixels of pointer travel as a drag, then swallow the click that follows it during the capture phase. Touch input skips the JavaScript drag entirely, so vertical page scrolling is never taken over.

const onClickCapture = (e) => {
  if (!dragged) return;
  e.preventDefault();
  e.stopPropagation();
  dragged = false;
};

Make it feel physical, then stop paying for it

Track the pointer's velocity while it is down, and keep moving after release with that velocity decaying each frame. Ignore it if the pointer rested before letting go, and clamp it so a flick cannot slingshot the track.

Writing scrollLeft every frame is layout work, so the tick returns early while an IntersectionObserver reports the track as off screen. Under prefers-reduced-motion the drift never starts, but the track stays scrollable and draggable.

Let’s Stay Connected

Got questions or want to collaborate? Feel free to reach out — I’m open to new projects or just a casual chat.

0%