GalleryFree

Velocity Marquee

Endless text bands that page scrolling pushes and tilts: they stop on hover, on keyboard focus or with the Pause button.

MarqueeScroll VelocitySeamlessrAF

Updated

SEAMLESS INFINITE VELOCITY MARQUEE NO GAP SCROLL PHYSICS
NEW DROP EFFECT.LABS VANILLA JS NO FRAMEWORK OPEN SOURCE CSS + JS
INERTIA SKEWX MASK-IMAGE RAF + LERP A11Y WCAG 2.2.2
Hover or click the scene to interact

This effect is free — the Gallery category contains 44 effects total, including 6 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

Bag (0)
DROP 07 RAW WINTER 300 PIECES MADE IN ROUBAIX
BOILED WOOL 14 OZ DENIM WAXED COTTON SHIPS NOV 14
Drop 07 · Nov 14

Raw winter

Twelve pieces cut in heavy fabrics. Scroll: the band speeds up with you.

Shop the drop
Wool coat289 €
Waxed jacket214 €
Wide 14 oz jeans139 €

① Fashion collection manifesto that speeds up as you scroll

WhenThe top of a limited collection page: under the navigation, a large “DROP 07 ✦ RAW WINTER ✦ 300 PIECES” band and a thin reversed band listing the fabrics, above the first three products.
WhyThe band reacts to the most common gesture on the page, scrolling: it gives rhythm to the arrival on the collection without video or heavy images, and stops as soon as the mouse rests on it so people can read.
Settingsdata-vmq-speed="70", data-vmq-skew="9"; large band --vmq-size:58px, small band data-dir="rtl" at 15 px; colors --vmq-color:#f5f5f4, --vmq-accent:#f97316 on the root; root height 124 px, transparent background.
Le Ferlanc InvestigationsPortraitsArchive Subscribe

By two in the morning the presses have stopped. Only the cutting room stays lit, where three people finish an order due before dawn.

CHAPTER II NIGHT WORKSHOPS 11 PM – 5 AM THE HANDS

II.What the night makes

Nobody here calls it night work. They say “the second shift”, as if the day simply carried on under the neon lights.

② Transition band between two chapters of a long read

WhenIn a long investigation on a magazine site, between the end of chapter I and the chapter II heading: a dark full-width band announces “CHAPTER II ✦ NIGHT WORKSHOPS ✦ 11 PM – 5 AM”.
WhyReaders scroll continuously: the band comes alive exactly when they move from one part to the next, marks the break like a print section heading and settles as soon as they stop scrolling.
Settingsdata-vmq-speed="42", data-vmq-skew="6"; a single 56 px band, 22 px text; background #1c1917, text --vmq-color:#f4efe6, accent #c2410c; Pause button vertically centered on the right.
BRISOLDE Tickets
Today
18:30North stage · Doors open 19:15Big top · Percussion workshop 20:30Main stage · Opening concert 22:00Courtyard · DJ set 23:40Lakeside · Fireworks
June 4 – 6 · By the lake

Brisolde 2027

Three days of music, forty concerts. Hover today's times to stop them and read.

Get a ticketFull line-up

③ Today's times in a festival header — the band stops under the mouse

WhenBelow a festival site header, a thin 32 px line introduced by “Today” runs the day's schedule (time in yellow, stage and event).
WhyThe times stay visible without taking room in the header; since the band stops on hover and focus, everyone can calmly read the time they care about.
Settingsdata-vmq-speed="38", data-vmq-skew="3"; 32 px band, 12.5 px text, time in a <b> inside each .vmq-item; root set to flex: 1 next to the “Today” label.

How it works

Every .vmq-root inside a .fx-0831-root is one band. The script keeps your items (.vmq-item, .vmq-sep) and appends just enough copies to cover the band width; the position moves in pixels per second based on elapsed time (same speed on a 60 Hz or 120 Hz screen) and wraps after exactly one set of items, so there is no visible seam.

The base speed (50 px/s) breathes slightly (±12%). Scrolling the page adds a push proportional to the pixels scrolled: scrolling down speeds the band up, scrolling up slows it and can briefly reverse it. Horizontal mouse movement inside the root, between the bands, does the same (on a band, stopping wins). The gap from the base speed leans the text (skewX, 7° at most), then everything eases back.

Bands in the same root share their state: data-dir="rtl" reverses one band, hovering or focusing any band stops them all in about 0.4 s, and the .vmq-pause-btn button freezes them. Two roots on a page stay independent. Off screen the loop stops; it rebuilds when the root width changes.

Accessibility

  • Pause: hovering a band or moving keyboard focus into it (a link inside the band, for instance) stops it; the Pause button carries aria-pressed and freezes every band of the root until the next click (WCAG 2.2.2).
  • Hidden copies: only your original items are read by screen readers; the copies added for the loop carry aria-hidden="true" and their links tabindex="-1", so nothing is announced or reached twice.
  • Reduced motion: under prefers-reduced-motion: reduce, no copy is created and nothing moves; each band becomes a line you scroll with a finger, the wheel or the keyboard (it gets tabindex="0" when its content overflows). The Pause button and the demo hint are hidden.
  • Decorative separators: the “✦” and “·” characters carry aria-hidden="true".

Browser compatibility

Pointer Events, IntersectionObserver and ResizeObserver (both feature-tested), mask-image with the -webkit- prefix. Measured on 2026-11-08 in Chromium, Firefox and Playwright's WebKit: speed, pause on hover, focus and button, two independent copies, reduced motion, touch at 390 px.

Chrome 64+✓ Full (measured)
Firefox 69+✓ Full (measured)
Safari 13.1+✓ Measured in WebKit, not in a real Safari
Edge 79+✓ Full (same engine as Chrome)
Safari iOS 13.4+✓ Not checked on a device
Chrome Android 64+✓ Touch measured in emulation (390 px)

Without JavaScript, each band shows your original items, still, clipped at the right edge. Without CSS masks, the band edges are sharp instead of fading out.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="fx-0831-root" data-vmq-speed="50" data-vmq-skew="7">
  <div class="vmq-demo-wrapper">
    <span class="vmq-demo-label" aria-hidden="true">move the cursor · scroll the page</span>

    <!-- upper secondary band (RTL, small) -->
    <div class="vmq-stripe vmq-stripe--sm">
      <div class="vmq-root" data-dir="rtl"
           style="--vmq-size:clamp(13px,2.4vw,20px);--vmq-gap:18px;--vmq-sep-gap:10px;">
        <div class="vmq-track">
          <span class="vmq-item" data-style="outline">SEAMLESS</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
          <span class="vmq-item" data-style="outline">INFINITE</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
          <span class="vmq-item" data-style="outline">VELOCITY</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
          <span class="vmq-item" data-style="outline">MARQUEE</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
          <span class="vmq-item" data-style="outline">NO GAP</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
          <span class="vmq-item" data-style="outline">SCROLL PHYSICS</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
        </div>
      </div>
    </div>

    <!-- main band (large type, LTR) -->
    <div class="vmq-stripe vmq-stripe--lg">
      <div class="vmq-root" style="--vmq-size:clamp(30px,6vw,56px);--vmq-gap:24px;">
        <div class="vmq-track">
          <span class="vmq-item">NEW DROP</span>
          <span class="vmq-sep" aria-hidden="true">✦</span>
          <span class="vmq-item" data-style="outline">EFFECT.LABS</span>
          <span class="vmq-sep" aria-hidden="true">✦</span>
          <span class="vmq-item">VANILLA JS</span>
          <span class="vmq-sep" aria-hidden="true">✦</span>
          <span class="vmq-item" data-style="outline">NO FRAMEWORK</span>
          <span class="vmq-sep" aria-hidden="true">✦</span>
          <span class="vmq-item">OPEN SOURCE</span>
          <span class="vmq-sep" aria-hidden="true">✦</span>
          <span class="vmq-item" data-style="outline">CSS + JS</span>
          <span class="vmq-sep" aria-hidden="true">✦</span>
        </div>
      </div>
    </div>

    <!-- lower secondary band (RTL, small) -->
    <div class="vmq-stripe vmq-stripe--sm">
      <div class="vmq-root" data-dir="rtl"
           style="--vmq-size:clamp(13px,2.4vw,20px);--vmq-gap:18px;--vmq-sep-gap:10px;">
        <div class="vmq-track">
          <span class="vmq-item" data-style="outline">INERTIA</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
          <span class="vmq-item" data-style="outline">SKEWX</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
          <span class="vmq-item" data-style="outline">MASK-IMAGE</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
          <span class="vmq-item" data-style="outline">RAF + LERP</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
          <span class="vmq-item" data-style="outline">A11Y</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
          <span class="vmq-item" data-style="outline">WCAG 2.2.2</span>
          <span class="vmq-sep" aria-hidden="true">·</span>
        </div>
      </div>
    </div>

    <button class="vmq-pause-btn" type="button" aria-pressed="false">
      <span aria-hidden="true">⏸</span>
      <span>Pause</span>
    </button>
  </div>
</div>
CSS
.fx-0831-root,
.fx-0831-root *,
.fx-0831-root *::before,
.fx-0831-root *::after {
  box-sizing: border-box
}

.fx-0831-root {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 300px;
  overflow: hidden;
  position: relative;
  background: rgb(26, 26, 36);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.vmq-root {
  position: relative;
  overflow: hidden;
  width: 100%;
  cursor: default;
  -webkit-mask-image: linear-gradient(to right,
      transparent 0%,
      #000 9%,
      #000 91%,
      transparent 100%);
  mask-image: linear-gradient(to right,
      transparent 0%,
      #000 9%,
      #000 91%,
      transparent 100%);
}

@supports not ((mask-image: linear-gradient(to right, #000, #000)) or (-webkit-mask-image: linear-gradient(to right, #000, #000))) {
  .vmq-root {
    border-radius: 0;
  }
}

.vmq-track {
  display: flex;
  align-items: center;
  white-space: nowrap;
  will-change: transform;
  transform-origin: left center;
  user-select: none;
  -webkit-user-select: none;
}

.vmq-item {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  padding: 0 var(--vmq-gap, 28px);
  font-size: var(--vmq-size, clamp(24px, 5vw, 48px));
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1;
  color: var(--vmq-color, #f1f5f9);
  white-space: nowrap;
  text-decoration: none;
}

.vmq-item[data-style="outline"] {
  color: transparent;
  -webkit-text-stroke: 1.5px var(--vmq-stroke, rgba(241, 245, 249, 0.45));
  text-stroke: 1.5px var(--vmq-stroke, rgba(241, 245, 249, 0.45));
}

.vmq-sep {
  flex-shrink: 0;
  padding: 0 var(--vmq-sep-gap, 16px);
  font-size: calc(var(--vmq-size, clamp(24px, 5vw, 48px)) * 0.60);
  color: var(--vmq-accent, #6366f1);
  line-height: 1;
  pointer-events: none;
  white-space: nowrap;
}

.vmq-demo-wrapper {
  position: relative;
  width: 100%;
  min-height: 300px;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: center;
  background: #06060e;
  border-radius: 14px;
  overflow: hidden;
  gap: 0;
}

.vmq-demo-label {
  position: absolute;
  top: 16px;
  left: 50%;
  transform: translateX(-50%);
  font-size: 11px;
  color: rgba(148, 163, 184, 0.30);
  letter-spacing: 0.10em;
  text-transform: uppercase;
  font-weight: 500;
  pointer-events: none;
  white-space: nowrap;
  z-index: 2;
}

.vmq-stripe {
  display: flex;
  align-items: center;
  flex: 1;
  min-height: 60px;
}

.vmq-stripe--lg {
  flex: 2;
  min-height: 100px;
}

.vmq-stripe--sm {
  flex: 1;
  opacity: 0.6;
  min-height: 56px;
}

.vmq-pause-btn {
  position: absolute;
  bottom: 14px;
  right: 14px;
  z-index: 10;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: rgba(15, 15, 26, 0.80);
  border: 1px solid rgba(99, 102, 241, 0.35);
  color: #c7d2fe;
  padding: 6px 12px 6px 10px;
  border-radius: 20px;
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  letter-spacing: 0.01em;
  transition: background 0.15s, border-color 0.15s;
  outline: 2px solid transparent;
  outline-offset: 2px;
}

.vmq-pause-btn:hover {
  background: rgba(30, 30, 50, 0.90);
  border-color: rgba(99, 102, 241, 0.65);
}

.vmq-pause-btn:focus-visible {
  outline-color: #818cf8;
}

.vmq-pause-btn[aria-pressed="true"] {
  background: rgba(49, 46, 129, 0.85);
  border-color: #6366f1;
  color: #e0e7ff;
}

/* reduced motion: still band, scrollable by hand (finger, wheel, keyboard) */
.vmq-root.vmq-static {
  overflow-x: auto;
  -webkit-mask-image: none;
  mask-image: none;
  scrollbar-width: thin;
  scrollbar-color: rgba(148, 163, 184, 0.35) transparent;
}

.vmq-root.vmq-static .vmq-track {
  transform: none;
  user-select: text;
  -webkit-user-select: text;
}

.vmq-root:focus-visible {
  outline: 2px solid #818cf8;
  outline-offset: -2px;
}

@media (prefers-reduced-motion: reduce) {
  .vmq-pause-btn,
  .vmq-demo-label {
    display: none;
  }
}
JavaScript (fx-0831)
(function () {
  /* fx-0831 — Velocity Marquee : one instance per .fx-0831-root */
  'use strict';

  var RM = window.matchMedia('(prefers-reduced-motion: reduce)');
  var FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]';

  function num(v, d, min, max) {
    var n = parseFloat(v);
    if (isNaN(n)) n = d;
    return Math.max(min, Math.min(max, n));
  }

  /* smoothing independent of the screen refresh rate (k = factor tuned for 60 fps) */
  function ease(k, dt) { return 1 - Math.pow(1 - k, dt * 60); }

  function setupRoot(root) {
    if (root.getAttribute('data-vmq-ready')) return;
    root.setAttribute('data-vmq-ready', '1');

    var BASE = num(root.getAttribute('data-vmq-speed'), 50, 5, 400);   /* px/s */
    var SKEW_MAX = num(root.getAttribute('data-vmq-skew'), 7, 0, 20);  /* degrees */
    var SCROLL_K = 3.6;   /* px/s added per px of page scroll */
    var MOUSE_K = 1.7;    /* px/s added per px of horizontal mouse movement */
    var SKEW_K = 0.0047;  /* degrees of lean per px/s away from the base speed */
    var BREATHE = 0.12;   /* slow breathing: ±12% of the speed */

    var btn = root.querySelector('.vmq-pause-btn');
    var bands = [];
    var vel = BASE, skew = 0, sBonus = 0, mBonus = 0, time = 0;
    var paused = false, visible = true, reduced = RM.matches;
    var raf = 0, lastT = 0, lastY = window.scrollY || 0, lastX = null;

    Array.prototype.forEach.call(root.querySelectorAll('.vmq-root'), function (el) {
      var track = el.querySelector('.vmq-track');
      if (!track) return;
      var b = {
        el: el, track: track, orig: Array.prototype.slice.call(track.children),
        setW: 0, pos: 0, rtl: el.getAttribute('data-dir') === 'rtl', hover: false, focus: false
      };
      el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') b.hover = true; });
      el.addEventListener('pointerleave', function () { b.hover = false; });
      el.addEventListener('focusin', function () { b.focus = true; });
      el.addEventListener('focusout', function () { b.focus = false; });
      bands.push(b);
    });
    if (!bands.length) return;

    function held() {
      for (var i = 0; i < bands.length; i++) if (bands[i].hover || bands[i].focus) return true;
      return false;
    }

    function clone(node) {
      var c = node.cloneNode(true);
      if (c.nodeType !== 1) return c;
      c.setAttribute('aria-hidden', 'true');
      c.removeAttribute('id');
      if (c.matches(FOCUSABLE)) c.setAttribute('tabindex', '-1');
      Array.prototype.forEach.call(c.querySelectorAll('[id]'), function (n) { n.removeAttribute('id'); });
      Array.prototype.forEach.call(c.querySelectorAll(FOCUSABLE), function (n) { n.setAttribute('tabindex', '-1'); });
      return c;
    }

    /* (re)builds each band: original set + copies hidden from screen readers */
    function build() {
      bands.forEach(function (b) {
        var t = b.track;
        while (t.lastChild && b.orig.indexOf(t.lastChild) < 0) t.removeChild(t.lastChild);
        t.style.transform = '';
        b.el.classList.toggle('vmq-static', reduced);
        b.el.removeAttribute('tabindex');
        if (reduced) {
          b.setW = 0;
          if (b.el.scrollWidth > b.el.clientWidth + 1) b.el.setAttribute('tabindex', '0');
          return;
        }
        var cw = b.el.clientWidth || 480;
        var sw = t.scrollWidth || 200;
        var n = Math.ceil(cw / sw) + 1;
        for (var i = 0; i < n; i++) b.orig.forEach(function (o) { t.appendChild(clone(o)); });
        var first = b.orig[0], twin = t.children[b.orig.length];
        var w = (first && twin) ? twin.getBoundingClientRect().left - first.getBoundingClientRect().left : 0;
        b.setW = w > 1 ? w : sw;
        b.pos = b.pos % b.setW;
      });
      if (!reduced) draw();
      run();
    }

    function draw() {
      bands.forEach(function (b) {
        if (!b.setW) return;
        var sk = b.rtl ? -skew : skew;
        b.track.style.transform = 'translateX(' + (-b.pos).toFixed(2) + 'px) skewX(' + sk.toFixed(3) + 'deg)';
      });
    }

    function frame(t) {
      raf = 0;
      if (reduced || paused || !visible) { lastT = 0; return; }
      raf = requestAnimationFrame(frame);
      var dt = lastT ? Math.min((t - lastT) / 1000, 0.05) : 0;
      lastT = t;
      if (!dt) return;

      var hold = held();
      time += 0.54 * dt;
      var target = hold ? 0 : BASE * (1 + Math.sin(time) * BREATHE) + sBonus + mBonus;
      sBonus *= Math.pow(0.80, dt * 60);
      mBonus *= Math.pow(0.84, dt * 60);
      /* hover or focus: quick stop (WCAG 2.2.2), otherwise smooth return */
      vel += (target - vel) * ease(hold ? 0.16 : 0.07, dt);
      if (hold && Math.abs(vel) < 0.5) vel = 0;

      var tSkew = hold ? 0 : Math.max(-SKEW_MAX, Math.min(SKEW_MAX, -(vel - BASE) * SKEW_K));
      skew += (tSkew - skew) * ease(0.09, dt);

      bands.forEach(function (b) {
        if (!b.setW) return;
        var step = (b.rtl ? -vel : vel) * dt;
        b.pos = ((b.pos + step) % b.setW + b.setW) % b.setW;
      });
      draw();
    }

    function run() {
      if (!raf && !reduced && !paused && visible) { lastT = 0; raf = requestAnimationFrame(frame); }
    }

    window.addEventListener('scroll', function () {
      var y = window.scrollY || 0;
      if (visible) sBonus = (y - lastY) * SCROLL_K;
      lastY = y;
    }, { passive: true });

    root.addEventListener('pointermove', function (e) {
      if (e.pointerType !== 'mouse') return;
      if (lastX !== null) mBonus = (e.clientX - lastX) * MOUSE_K;
      lastX = e.clientX;
    });
    root.addEventListener('pointerleave', function () { lastX = null; });

    if (btn) {
      btn.addEventListener('click', function () {
        paused = !paused;
        btn.setAttribute('aria-pressed', paused ? 'true' : 'false');
        var icon = btn.querySelector('[aria-hidden="true"]');
        if (icon) icon.textContent = paused ? '▶' : '⏸';
        if (paused) { vel = 0; skew = 0; draw(); } else { vel = 0; run(); }
      });
    }

    if (RM.addEventListener) {
      RM.addEventListener('change', function (e) { reduced = e.matches; skew = 0; build(); });
    }

    if ('IntersectionObserver' in window) {
      new IntersectionObserver(function (en) {
        visible = en[0].isIntersecting;
        run();
      }).observe(root);
    }

    var lastW = root.clientWidth, pending = 0;
    if ('ResizeObserver' in window) {
      new ResizeObserver(function () {
        if (!root.clientWidth || root.clientWidth === lastW || pending) return;
        pending = requestAnimationFrame(function () { pending = 0; lastW = root.clientWidth; build(); });
      }).observe(root);
    }

    build();
    if (document.fonts && document.fonts.ready) document.fonts.ready.then(build);
  }

  function init() {
    Array.prototype.forEach.call(document.querySelectorAll('.fx-0831-root'), setupRoot);
  }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
  else init();
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Speed (HTML — data-vmq-speed on .fx-0831-root) 50 (px/s) Cruising speed of every band in the root, from 5 to 400 px/s. Page scrolling adds to this value.
Maximum lean (HTML — data-vmq-skew) 7 (degrees) Largest skewX angle taken when the band speeds up or slows down. 0 removes the lean.
Band direction (HTML — data-dir="rtl" on .vmq-root) Absent: moves left The band moves right; the scroll push is reversed too.
Content (HTML — children of .vmq-track) Six words and six separators per band Use your own .vmq-item: text, <a class="vmq-item" href="…">, an <img> logo inside an item. data-style="outline" shows a word as an outline.
Size and spacing (CSS — --vmq-size, --vmq-gap, --vmq-sep-gap on .vmq-root) clamp(24px, 5vw, 48px), 28 px, 16 px Text size, space on each side of a word and around separators.
Colors (CSS — --vmq-color, --vmq-stroke, --vmq-accent) #f1f5f9, rgba(241,245,249,.45), #6366f1 Solid text, outline of outline words and separators; set them on the root or on one band.
Pause button (HTML — .vmq-pause-btn) Present You may remove it, but then keep another way for keyboard users to stop the motion (WCAG 2.2.2).

FAQ

Why does the band sometimes run backwards?

Scrolling up removes speed: a quick flick goes past the base speed and the band backs up for a moment, then the push fades (it loses about 20% per frame) and the band returns to its normal direction.

Can I put logos or links in the band?

Yes: every child of .vmq-track is kept. Links stay clickable, keyboard focus stops the band, and the copies made for the loop are removed from the keyboard order and from screen readers. Give images a fixed height so the width measured at start is right.

My fonts load late: will the loop jump?

No. The script rebuilds the bands once document.fonts.ready resolves and whenever the root width changes, so one lap always matches the words actually displayed.