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.