MUST

·

Loop · velocity

Velocity marquee

Everybody builds a marquee. Almost nobody builds one that reacts, and that single difference is the whole effect — a static ticker is decoration, a ticker that answers your scroll makes the page feel alive. The mechanism is simpler than the CSS version people usually reach for: two identical tracks side by side, moved by exactly one track width, repeating forever. The seam is invisible because at the moment the loop restarts, the second copy is sitting exactly where the first one started. Then ScrollTrigger.getVelocity() drives the tween's timeScale, and self.direction flips its sign.

Open the live demo →

How to build

Panel + script

Free plan gets you part of the way

The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.

How the two routes work →

Plugins

ScrollTrigger · quickTo

Build time

25 min. The velocity part is the last five.

No-code?

The panel's Repetition feature loops a tween, so a plain marquee is no-code. Velocity is not — getVelocity() has no UI equivalent, so the reactive version is code only.

01

Webflow structure

element · class · attribute

<b>Div</b> <i>marquee_row</i> <u>data-marquee data-marquee-speed="22" data-marquee-dir="1"</u> <b>Div</b> <i>marquee_track</i> ← copy 1, the real content <b>Div</b> <i>marquee_item</i> <b>Text</b> Kelela <b>Text</b> <i>marquee_sep</i> ◆ <b>Div</b> <i>marquee_item</i> … <b>Div</b> <i>marquee_track</i> <u>aria-hidden="true"</u> ← identical copy 2 (duplicate the exact same items — in CMS, a second Collection List with the same source and the same limit)

02

Styles to set in the Designer

marquee_row | <code>display: flex; overflow: hidden;</code> plus <code>user-select: none</code>. This is the window. marquee_track | <code>display: flex; flex: none;</code> — <strong><code>flex: none</code> is the one everybody forgets.</strong> Without it flexbox squashes both tracks to fit the row and nothing loops. marquee_item | <code>flex: none; white-space: nowrap;</code> and put the gap on <code>padding-right</code>, not on a flex gap — gap on the track adds a half-gap at the seam. marquee_wrap (page level) | The <strong>page</strong> needs <code>overflow-x: hidden</code> on the section, not on <code>body</code>. Setting it on body kills <code>position: sticky</code> everywhere else on the site.

03

Building it with the Interactions panel

no code, free plan
  1. Select marquee_track → Interactions → trigger Page load → animate Move X from 0% to -100%, duration 22–30s, Ease None.
  2. Turn on the interaction's Repetition setting (Loop) so it repeats indefinitely.
  3. Duplicate the track exactly — same items, same order — for the second copy, and set its resting Move X to -100% so the two tile seamlessly.

What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.

  • There's no panel equivalent of getVelocity(), so a panel-built marquee can't speed up on a hard scroll or reverse direction on scroll-up — it plays at one constant rate forever. The reactive part of this recipe is entirely script.

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

marquee_row | data-marquee | (empty) | Marks the row. Everything else is optional. marquee_row | data-marquee-speed | 22 | Seconds for one full pass. Bigger = slower. 20–30 for text, 30–40 for logos. marquee_row | data-marquee-dir | 1 or -1 | Stack two rows with opposite directions — that opposition is what stops it reading as a stock ticker.

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; document.querySelectorAll("[data-marquee]").forEach(row => { const tracks = row.querySelectorAll(".marquee_track"); const duration = parseFloat(row.dataset.marqueeSpeed || "24"); const dir = parseFloat(row.dataset.marqueeDir || "1"); // Move BOTH copies by exactly one track width. When the tween loops, // copy 2 is sitting precisely where copy 1 began — no visible seam, // and no measuring of pixel widths that break on resize. const loop = gsap.fromTo(tracks, { xPercent: dir === 1 ? 0 : -100 }, { xPercent: dir === 1 ? -100 : 0, ease: "none", duration, repeat: -1 } ); // Two independent multipliers on one timeScale: scroll velocity, and hover. const proxy = { ts: 1, hover: 1 }; const apply = () => loop.timeScale(proxy.ts * proxy.hover); // quickTo reuses ONE tween instead of creating a new one every scroll // frame. On a scroll handler that difference is real. const setTs = gsap.quickTo(proxy, "ts", { duration: 0.55, ease: "power3.out", onUpdate: apply }); ScrollTrigger.create({ trigger: row, start: "top bottom", end: "bottom top", onUpdate(self) { const boost = gsap.utils.clamp(1, 7, 1 + Math.abs(self.getVelocity()) / 700); setTs(self.direction * boost); // direction is 1 down / -1 up } }); row.addEventListener("mouseenter", () => gsap.to(proxy, { hover: 0.2, duration: 0.5, onUpdate: apply })); row.addEventListener("mouseleave", () => gsap.to(proxy, { hover: 1, duration: 0.5, onUpdate: apply })); }); }); </script>

07

Gotchas that will cost you an hour

  1. flex: none on the track. This is the bug. Without it flexbox shrinks both tracks to fit the row, the transform moves them a fraction of a pixel, and you spend twenty minutes convinced GSAP is broken.
  2. The two copies must be byte-identical. One extra separator, one different trailing space, and the seam becomes a visible stutter once per loop. With CMS, duplicate the Collection List and give it the same limit and sort.
  3. Put aria-hidden="true" on copy 2. Otherwise a screen reader reads your entire lineup twice.
  4. Never gsap.to(loop, {timeScale}) inside onUpdate. Scroll fires dozens of times a second and you would create a tween per frame. quickTo exists precisely for this.
  5. Overflow hidden belongs on the section, not on body. overflow: hidden on body or html silently disables every position: sticky on the site — including the stacking cards in the stacking-cards lesson.
  6. If the content is narrower than the viewport, it will never look seamless. Duplicate the items inside each track until one track is wider than the widest breakpoint you support.
08

Push it further

  • Add a third multiplier driven by Draggable so the row can be flicked by hand and settles back into its loop.
  • Swap xPercent for yPercent in a tall thin column — a vertical marquee down the side of a hero is a strong, rarely-used move.
  • Feed the same velocity value into a skewX on the items (clamped to about 8 degrees). It is the classic “speed lines” effect and it is one extra quickTo.

Webflow Motion Lab — GSAP 3.15, all plugins free.

8

/ 30